= ({ onItemSelect }
return (
-
+
+
Commands
+
Total {commandOnlyItems.length}
-
-
-
+
+
@@ -264,12 +253,12 @@ export const CommandsSidebar: React.FC
= ({ onItemSelect }
onSelect={() => {
setSelectedCommand(command.name);
onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
+
}}
onReset={() => handleResetCommand(command)}
onDuplicate={() => handleDuplicateCommand(command)}
+ isMenuOpen={openMenuCommand === command.name}
+ onMenuOpenChange={(open) => setOpenMenuCommand(open ? command.name : null)}
/>
))}
>
@@ -288,13 +277,13 @@ export const CommandsSidebar: React.FC = ({ onItemSelect }
onSelect={() => {
setSelectedCommand(command.name);
onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
+
}}
onRename={() => handleOpenRenameDialog(command)}
onDelete={() => handleDeleteCommand(command)}
onDuplicate={() => handleDuplicateCommand(command)}
+ isMenuOpen={openMenuCommand === command.name}
+ onMenuOpenChange={(open) => setOpenMenuCommand(open ? command.name : null)}
/>
))}
>
@@ -321,14 +310,13 @@ export const CommandsSidebar: React.FC = ({ onItemSelect }
-
Cancel
-
+
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
@@ -357,13 +345,12 @@ export const CommandsSidebar: React.FC = ({ onItemSelect }
}}
/>
- setRenameDialogCommand(null)}
- className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
-
+
Rename
@@ -382,6 +369,8 @@ interface CommandListItemProps {
onReset?: () => void;
onRename?: () => void;
onDuplicate: () => void;
+ isMenuOpen: boolean;
+ onMenuOpenChange: (open: boolean) => void;
}
const CommandListItem: React.FC = ({
@@ -392,13 +381,20 @@ const CommandListItem: React.FC = ({
onReset,
onRename,
onDuplicate,
+ isMenuOpen,
+ onMenuOpenChange,
}) => {
+ const isMobile = isMobileDeviceViaCSS();
return (
{
+ e.preventDefault();
+ onMenuOpenChange(true);
+ } : undefined}
>
= ({
)}
-
+
-
-
+
{onRename && (
diff --git a/packages/ui/src/components/sections/git-identities/GitIdentitiesPage.tsx b/packages/ui/src/components/sections/git-identities/GitIdentitiesPage.tsx
deleted file mode 100644
index 1dc27d9d..00000000
--- a/packages/ui/src/components/sections/git-identities/GitIdentitiesPage.tsx
+++ /dev/null
@@ -1,533 +0,0 @@
-import React from 'react';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { toast } from '@/components/ui';
-import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
-import {
- RiUser3Line,
- RiSaveLine,
- RiDeleteBinLine,
- RiGitBranchLine,
- RiBriefcaseLine,
- RiHomeLine,
- RiGraduationCapLine,
- RiCodeLine,
- RiInformationLine,
- RiKeyLine,
- RiLock2Line
-} from '@remixicon/react';
-import { cn } from '@/lib/utils';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-
-const PROFILE_COLORS = [
- { key: 'keyword', label: 'Green', cssVar: 'var(--syntax-keyword)' },
- { key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
- { key: 'string', label: 'Cyan', cssVar: 'var(--syntax-string)' },
- { key: 'function', label: 'Orange', cssVar: 'var(--syntax-function)' },
- { key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
-];
-
-const PROFILE_ICONS = [
- { key: 'branch', Icon: RiGitBranchLine, label: 'Branch' },
- { key: 'briefcase', Icon: RiBriefcaseLine, label: 'Work' },
- { key: 'house', Icon: RiHomeLine, label: 'Personal' },
- { key: 'graduation', Icon: RiGraduationCapLine, label: 'School' },
- { key: 'code', Icon: RiCodeLine, label: 'Code' },
-];
-
-export const GitIdentitiesPage: React.FC = () => {
- const {
- selectedProfileId,
- getProfileById,
- createProfile,
- updateProfile,
- deleteProfile,
- } = useGitIdentitiesStore();
-
- // Parse import: prefix for credential import flow
- const importData = React.useMemo(() => {
- if (selectedProfileId?.startsWith('import:')) {
- const [, host, username] = selectedProfileId.split(':');
- return { host, username };
- }
- return null;
- }, [selectedProfileId]);
-
- const selectedProfile = React.useMemo(() =>
- selectedProfileId && selectedProfileId !== 'new' && !importData ? getProfileById(selectedProfileId) : null,
- [selectedProfileId, getProfileById, importData]
- );
- const isNewProfile = selectedProfileId === 'new' || importData !== null;
- const isGlobalProfile = selectedProfileId === 'global';
-
- const [name, setName] = React.useState('');
- const [userName, setUserName] = React.useState('');
- const [userEmail, setUserEmail] = React.useState('');
- const [authType, setAuthType] = React.useState('ssh');
- const [sshKey, setSshKey] = React.useState('');
- const [host, setHost] = React.useState('');
- const [color, setColor] = React.useState('keyword');
- const [icon, setIcon] = React.useState('branch');
- const [isSaving, setIsSaving] = React.useState(false);
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
- const [isDeleting, setIsDeleting] = React.useState(false);
-
- React.useEffect(() => {
- if (importData) {
- const parts = importData.host.split('/');
- const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host;
-
- setName(displayName);
- setUserName(importData.username);
- setUserEmail('');
- setAuthType('token');
- setSshKey('');
- setHost(importData.host);
- setColor('string');
- setIcon('code');
- } else if (isNewProfile) {
- setName('');
- setUserName('');
- setUserEmail('');
- setAuthType('ssh');
- setSshKey('');
- setHost('');
- setColor('keyword');
- setIcon('branch');
- } else if (selectedProfile) {
- setName(selectedProfile.name);
- setUserName(selectedProfile.userName);
- setUserEmail(selectedProfile.userEmail);
- setAuthType(selectedProfile.authType || 'ssh');
- setSshKey(selectedProfile.sshKey || '');
- setHost(selectedProfile.host || '');
- setColor(selectedProfile.color || 'keyword');
- setIcon(selectedProfile.icon || 'branch');
- }
- }, [selectedProfile, isNewProfile, selectedProfileId, importData]);
-
- const handleSave = async () => {
- if (!userName.trim() || !userEmail.trim()) {
- toast.error('User name and email are required');
- return;
- }
-
- if (authType === 'token' && !host.trim()) {
- toast.error('Host is required for token-based authentication');
- return;
- }
-
- setIsSaving(true);
-
- try {
- const profileData: Omit & { id?: string } = {
- name: name.trim() || userName.trim(),
- userName: userName.trim(),
- userEmail: userEmail.trim(),
- authType,
- sshKey: authType === 'ssh' ? (sshKey.trim() || null) : null,
- host: authType === 'token' ? (host.trim() || null) : null,
- color,
- icon,
- };
-
- let success: boolean;
- if (isNewProfile) {
- success = await createProfile(profileData);
- } else if (selectedProfileId) {
- success = await updateProfile(selectedProfileId, profileData);
- } else {
- return;
- }
-
- if (success) {
- toast.success(isNewProfile ? 'Profile created successfully' : 'Profile updated successfully');
- } else {
- toast.error(isNewProfile ? 'Failed to create profile' : 'Failed to update profile');
- }
- } catch (error) {
- console.error('Error saving profile:', error);
- toast.error('An error occurred while saving');
- } finally {
- setIsSaving(false);
- }
- };
-
- const handleDelete = () => {
- if (!selectedProfileId || isNewProfile) return;
-
- setIsDeleteDialogOpen(true);
- };
-
- const handleConfirmDelete = async () => {
- if (!selectedProfileId || isNewProfile) {
- return;
- }
-
- setIsDeleting(true);
- try {
- const success = await deleteProfile(selectedProfileId);
- if (success) {
- toast.success('Profile deleted successfully');
- setIsDeleteDialogOpen(false);
- } else {
- toast.error('Failed to delete profile');
- }
- } catch (error) {
- console.error('Error deleting profile:', error);
- toast.error('An error occurred while deleting');
- } finally {
- setIsDeleting(false);
- }
- };
-
- const currentColorValue = React.useMemo(() => {
- const colorConfig = PROFILE_COLORS.find(c => c.key === color);
- return colorConfig?.cssVar || 'var(--syntax-keyword)';
- }, [color]);
-
- if (!selectedProfileId) {
- return (
-
-
-
-
Select a profile from the sidebar
-
or create a new one
-
-
- );
- }
-
- return (
-
-
- {/* Header */}
-
-
- {importData ? 'Import Credential' : isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}
-
-
- {importData
- ? `Import token credential for ${importData.host} - please fill in your email address`
- : isNewProfile
- ? 'Create a new Git identity profile for your repositories'
- : isGlobalProfile
- ? 'System-wide Git identity from global configuration (read-only)'
- : 'Configure Git identity settings for this profile'}
-
-
-
- {}
- {!isGlobalProfile && (
-
-
-
Profile Information
-
- Basic profile settings and display name
-
-
-
-
-
- Display Name
-
-
setName(e.target.value)}
- placeholder="Work Profile, Personal, etc."
- />
-
- Friendly name to identify this profile (optional, defaults to user name)
-
-
-
-
-
-
- Color
-
-
- {PROFILE_COLORS.map((c) => (
- 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}
- />
- ))}
-
-
-
-
-
- Icon
-
-
- {PROFILE_ICONS.map((i) => {
- const IconComponent = i.Icon;
- return (
- setIcon(i.key)}
- className={cn(
- 'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
- icon === i.key
- ? 'border-primary bg-accent scale-110'
- : 'border-border hover:border-primary/50'
- )}
- title={i.label}
- >
-
-
- );
- })}
-
-
-
-
- )}
-
- {}
-
-
-
Git Configuration
-
- Git user settings that will be applied to repositories
-
-
-
-
-
- User Name {!isGlobalProfile && * }
-
-
-
-
-
- The name that will appear in Git commit messages.
- This is the author name shown in git log and GitHub/GitLab interfaces.
-
-
-
-
setUserName(e.target.value)}
- placeholder="John Doe"
- required={!isGlobalProfile}
- readOnly={isGlobalProfile}
- disabled={isGlobalProfile}
- />
-
- Git user.name configuration value
-
-
-
-
-
- User Email {!isGlobalProfile && * }
-
-
-
-
-
- The email address for Git commits.
- This should match your email in GitHub/GitLab
- to ensure proper attribution of commits.
-
-
-
-
setUserEmail(e.target.value)}
- placeholder="john@example.com"
- required={!isGlobalProfile}
- readOnly={isGlobalProfile}
- disabled={isGlobalProfile}
- />
-
- Git user.email configuration value
-
-
-
- {/* Auth Type Selector */}
- {!isGlobalProfile && (
-
-
- Authentication Type
-
-
-
-
-
- SSH: Uses SSH key for authentication
- Token: Uses personal access token from ~/.git-credentials
-
-
-
-
- setAuthType('ssh')}
- className={cn(
- 'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
- authType === 'ssh'
- ? 'border-primary bg-accent'
- : 'border-border hover:border-primary/50'
- )}
- >
-
- SSH Key
-
- setAuthType('token')}
- className={cn(
- 'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
- authType === 'token'
- ? 'border-primary bg-accent'
- : 'border-border hover:border-primary/50'
- )}
- >
-
- Token (HTTPS)
-
-
-
- )}
-
- {/* SSH Key Path - only for SSH auth type */}
- {authType === 'ssh' && (
-
-
- SSH Key Path
-
-
-
-
-
- Path to SSH private key used for Git authentication.
- This key will be used for SSH Git operations.
- Common paths: ~/.ssh/id_rsa, ~/.ssh/id_ed25519
-
-
-
-
setSshKey(e.target.value)}
- placeholder="/Users/username/.ssh/id_rsa"
- readOnly={isGlobalProfile}
- disabled={isGlobalProfile}
- />
-
- Path to SSH private key for authentication (optional)
-
-
- )}
-
- {/* Host - only for Token auth type */}
- {authType === 'token' && !isGlobalProfile && (
-
-
- Host {* }
-
-
-
-
-
- The Git host this credential applies to.
- Token will be read from ~/.git-credentials for this host.
- Examples: github.com, gitlab.com
-
-
-
-
setHost(e.target.value)}
- placeholder="github.com"
- required
- />
-
- Git host for token authentication (from ~/.git-credentials)
-
-
- )}
-
- {}
- {!isGlobalProfile && (
-
- {!isNewProfile && (
-
-
- Delete Profile
-
- )}
-
-
-
- {isSaving ? 'Saving...' : 'Save Profile'}
-
-
-
- )}
-
-
-
{
- if (!isDeleting) {
- setIsDeleteDialogOpen(open);
- }
- }}
- >
-
-
- Delete Profile
-
- Are you sure you want to delete profile "{selectedProfile?.name || name || 'this profile'}"?
-
-
-
- setIsDeleteDialogOpen(false)} disabled={isDeleting}>
- Cancel
-
- void handleConfirmDelete()} disabled={isDeleting}>
- Delete
-
-
-
-
-
-
- );
-};
diff --git a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx b/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx
deleted file mode 100644
index eeb4c7f6..00000000
--- a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx
+++ /dev/null
@@ -1,416 +0,0 @@
-import React from 'react';
-import { Button } from '@/components/ui/button';
-import { toast } from '@/components/ui';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import {
- RiAddLine,
- RiGitBranchLine,
- RiMore2Line,
- RiDeleteBinLine,
- RiBriefcaseLine,
- RiHomeLine,
- RiGraduationCapLine,
- RiCodeLine,
- RiHeartLine,
- RiDownloadLine,
-} from '@remixicon/react';
-import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
-import { useUIStore } from '@/stores/useUIStore';
-import { useDeviceInfo } from '@/lib/device';
-import { isVSCodeRuntime } from '@/lib/desktop';
-import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
-import { cn } from '@/lib/utils';
-import type { GitIdentityProfile, DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
-
-const ICON_MAP: Record> = {
- branch: RiGitBranchLine,
- briefcase: RiBriefcaseLine,
- house: RiHomeLine,
- graduation: RiGraduationCapLine,
- code: RiCodeLine,
- heart: RiHeartLine,
-};
-
-const COLOR_MAP: Record = {
- keyword: 'var(--syntax-keyword)',
- error: 'var(--status-error)',
- string: 'var(--syntax-string)',
- function: 'var(--syntax-function)',
- type: 'var(--syntax-type)',
-};
-
-interface GitIdentitiesSidebarProps {
- onItemSelect?: () => void;
-}
-
-export const GitIdentitiesSidebar: React.FC = ({ onItemSelect }) => {
- const [deleteDialogProfile, setDeleteDialogProfile] = React.useState(null);
- const [isDeletePending, setIsDeletePending] = React.useState(false);
-
- const {
- selectedProfileId,
- defaultGitIdentityId,
- profiles,
- globalIdentity,
- setSelectedProfile,
- deleteProfile,
- loadProfiles,
- loadGlobalIdentity,
- loadDiscoveredCredentials,
- loadDefaultGitIdentityId,
- setDefaultGitIdentityId,
- getUnimportedCredentials,
- } = useGitIdentitiesStore();
-
- const { setSidebarOpen } = useUIStore();
- const { isMobile } = useDeviceInfo();
-
- const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
-
- const unimportedCredentials = getUnimportedCredentials();
-
- React.useEffect(() => {
- loadProfiles();
- loadGlobalIdentity();
- loadDiscoveredCredentials();
- loadDefaultGitIdentityId();
- }, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]);
-
- const handleImportCredential = (credential: DiscoveredGitCredential) => {
- // Set a special "import" selection that carries the credential data
- // The form will read this and pre-fill fields
- setSelectedProfile(`import:${credential.host}:${credential.username}`);
- onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
- };
-
- const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
-
- const handleCreateProfile = () => {
- setSelectedProfile('new');
- onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
- };
-
- const handleDeleteProfile = async (profile: GitIdentityProfile) => {
- setDeleteDialogProfile(profile);
- };
-
- const handleConfirmDeleteProfile = async () => {
- if (!deleteDialogProfile) {
- return;
- }
-
- setIsDeletePending(true);
- const success = await deleteProfile(deleteDialogProfile.id);
- if (success) {
- toast.success(`Profile "${deleteDialogProfile.name}" deleted successfully`);
- setDeleteDialogProfile(null);
- } else {
- toast.error('Failed to delete profile');
- }
- setIsDeletePending(false);
- };
-
- const handleToggleDefault = async (profileId: string) => {
- const next = defaultGitIdentityId === profileId ? null : profileId;
- const ok = await setDefaultGitIdentityId(next);
- if (!ok) {
- toast.error('Failed to update default identity');
- return;
- }
- toast.success(next ? 'Default identity updated' : 'Default identity unset');
- };
-
- return (
-
-
-
- Total {profiles.length}
-
-
-
-
-
-
-
- {}
- {globalIdentity && (
- <>
-
- System Default
-
- {
- setSelectedProfile('global');
- onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
- }}
- onToggleDefault={() => handleToggleDefault('global')}
- onDelete={undefined}
- isReadOnly
- />
- >
- )}
-
- {}
- {profiles.length > 0 && (
-
- Custom Profiles
-
- )}
-
- {profiles.length === 0 && !globalIdentity && unimportedCredentials.length === 0 ? (
-
-
-
No profiles configured
-
Use the + button above to create one
-
- ) : (
- <>
- {profiles.map((profile) => (
- {
- setSelectedProfile(profile.id);
- onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
- }}
- onToggleDefault={() => handleToggleDefault(profile.id)}
- onDelete={() => handleDeleteProfile(profile)}
- />
- ))}
- >
- )}
-
- {/* Discovered Credentials Section */}
- {unimportedCredentials.length > 0 && (
- <>
-
- Discovered Credentials
-
-
- Found in ~/.git-credentials
-
- {unimportedCredentials.map((cred) => (
- handleImportCredential(cred)}
- />
- ))}
- >
- )}
-
-
-
{
- if (!open && !isDeletePending) {
- setDeleteDialogProfile(null);
- }
- }}
- >
-
-
- Delete Profile
-
- Are you sure you want to delete profile "{deleteDialogProfile?.name}"?
-
-
-
- setDeleteDialogProfile(null)} disabled={isDeletePending}>
- Cancel
-
- void handleConfirmDeleteProfile()} disabled={isDeletePending}>
- Delete
-
-
-
-
-
- );
-};
-
-interface ProfileListItemProps {
- profile: GitIdentityProfile;
- isSelected: boolean;
- isDefault?: boolean;
- onSelect: () => void;
- onToggleDefault?: () => void | Promise;
- onDelete?: () => void;
- isReadOnly?: boolean;
-}
-
-const ProfileListItem: React.FC = ({
- profile,
- isSelected,
- isDefault = false,
- onSelect,
- onToggleDefault,
- onDelete,
- isReadOnly = false,
-}) => {
- const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
- const iconColor = COLOR_MAP[profile.color || ''];
- const authType = profile.authType || 'ssh';
-
- return (
-
-
-
-
-
-
- {profile.name}
-
-
- {authType}
-
- {isDefault && (
-
- default
-
- )}
-
-
-
- {authType === 'token' && profile.host ? profile.host : profile.userEmail}
-
-
-
- {(onToggleDefault || (!isReadOnly && onDelete)) && (
-
-
-
-
-
-
-
- {onToggleDefault && (
- {
- e.stopPropagation();
- void onToggleDefault();
- }}
- >
- {isDefault ? 'Unset default' : 'Set as default'}
-
- )}
- {!isReadOnly && onDelete && (
- {
- e.stopPropagation();
- onDelete();
- }}
- className="text-destructive focus:text-destructive"
- >
-
- Delete
-
- )}
-
-
- )}
-
-
- );
-};
-
-interface DiscoveredCredentialItemProps {
- credential: DiscoveredGitCredential;
- onImport: () => void;
-}
-
-const getCredentialDisplayName = (host: string): string => {
- const parts = host.split('/');
- if (parts.length >= 3) {
- return parts[parts.length - 1];
- }
- return host;
-};
-
-const DiscoveredCredentialItem: React.FC = ({
- credential,
- onImport,
-}) => {
- const displayName = getCredentialDisplayName(credential.host);
- const isRepoSpecific = credential.host.includes('/');
-
- return (
-
-
-
-
-
- {displayName}
-
-
-
- {isRepoSpecific ? credential.host : credential.username}
-
-
-
-
- Import
-
-
-
- );
-};
diff --git a/packages/ui/src/components/sections/git-identities/GitIdentityEditorDialog.tsx b/packages/ui/src/components/sections/git-identities/GitIdentityEditorDialog.tsx
new file mode 100644
index 00000000..d18698eb
--- /dev/null
+++ b/packages/ui/src/components/sections/git-identities/GitIdentityEditorDialog.tsx
@@ -0,0 +1,478 @@
+import React from 'react';
+import { Button } from '@/components/ui/button';
+import { ButtonSmall } from '@/components/ui/button-small';
+import { Input } from '@/components/ui/input';
+import { toast } from '@/components/ui';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
+import {
+ RiDeleteBinLine,
+ RiGitBranchLine,
+ RiBriefcaseLine,
+ RiHomeLine,
+ RiGraduationCapLine,
+ RiCodeLine,
+ RiInformationLine,
+ RiKeyLine,
+ RiLock2Line,
+} from '@remixicon/react';
+import { cn } from '@/lib/utils';
+
+const PROFILE_COLORS = [
+ { key: 'keyword', label: 'Green', cssVar: 'var(--syntax-keyword)' },
+ { key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
+ { key: 'string', label: 'Cyan', cssVar: 'var(--syntax-string)' },
+ { key: 'function', label: 'Orange', cssVar: 'var(--syntax-function)' },
+ { key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
+];
+
+const PROFILE_ICONS = [
+ { key: 'branch', Icon: RiGitBranchLine, label: 'Branch' },
+ { key: 'briefcase', Icon: RiBriefcaseLine, label: 'Work' },
+ { key: 'house', Icon: RiHomeLine, label: 'Personal' },
+ { key: 'graduation', Icon: RiGraduationCapLine, label: 'School' },
+ { key: 'code', Icon: RiCodeLine, label: 'Code' },
+];
+
+interface GitIdentityEditorDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Profile ID to edit, 'new' for creation, or null */
+ profileId: string | null;
+ /** Pre-fill data for importing a discovered credential */
+ importData?: { host: string; username: string } | null;
+}
+
+export const GitIdentityEditorDialog: React.FC = ({
+ open,
+ onOpenChange,
+ profileId,
+ importData,
+}) => {
+ const {
+ getProfileById,
+ createProfile,
+ updateProfile,
+ deleteProfile,
+ } = useGitIdentitiesStore();
+
+ const selectedProfile = React.useMemo(() =>
+ profileId && profileId !== 'new' && !importData ? getProfileById(profileId) : null,
+ [profileId, getProfileById, importData]
+ );
+ const isNewProfile = profileId === 'new' || importData != null;
+ const isGlobalProfile = profileId === 'global';
+
+ const [name, setName] = React.useState('');
+ const [userName, setUserName] = React.useState('');
+ const [userEmail, setUserEmail] = React.useState('');
+ const [authType, setAuthType] = React.useState('ssh');
+ const [sshKey, setSshKey] = React.useState('');
+ const [host, setHost] = React.useState('');
+ const [color, setColor] = React.useState('keyword');
+ const [icon, setIcon] = React.useState('branch');
+ const [isSaving, setIsSaving] = React.useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
+ const [isDeleting, setIsDeleting] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!open) return;
+ if (importData) {
+ const parts = importData.host.split('/');
+ const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host;
+ setName(displayName);
+ setUserName(importData.username);
+ setUserEmail('');
+ setAuthType('token');
+ setSshKey('');
+ setHost(importData.host);
+ setColor('string');
+ setIcon('code');
+ } else if (isNewProfile) {
+ setName('');
+ setUserName('');
+ setUserEmail('');
+ setAuthType('ssh');
+ setSshKey('');
+ setHost('');
+ setColor('keyword');
+ setIcon('branch');
+ } else if (selectedProfile) {
+ setName(selectedProfile.name);
+ setUserName(selectedProfile.userName);
+ setUserEmail(selectedProfile.userEmail);
+ setAuthType(selectedProfile.authType || 'ssh');
+ setSshKey(selectedProfile.sshKey || '');
+ setHost(selectedProfile.host || '');
+ setColor(selectedProfile.color || 'keyword');
+ setIcon(selectedProfile.icon || 'branch');
+ } else if (isGlobalProfile) {
+ const global = getProfileById('global');
+ if (global) {
+ setName(global.name);
+ setUserName(global.userName);
+ setUserEmail(global.userEmail);
+ setAuthType(global.authType || 'ssh');
+ setSshKey(global.sshKey || '');
+ setHost(global.host || '');
+ setColor(global.color || 'keyword');
+ setIcon(global.icon || 'branch');
+ }
+ }
+ }, [open, profileId, selectedProfile, isNewProfile, importData, isGlobalProfile, getProfileById]);
+
+ const handleSave = async () => {
+ if (!userName.trim() || !userEmail.trim()) {
+ toast.error('User name and email are required');
+ return;
+ }
+ if (authType === 'token' && !host.trim()) {
+ toast.error('Host is required for token-based authentication');
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ const profileData: Omit & { id?: string } = {
+ name: name.trim() || userName.trim(),
+ userName: userName.trim(),
+ userEmail: userEmail.trim(),
+ authType,
+ sshKey: authType === 'ssh' ? (sshKey.trim() || null) : null,
+ host: authType === 'token' ? (host.trim() || null) : null,
+ color,
+ icon,
+ };
+
+ let success: boolean;
+ if (isNewProfile) {
+ success = await createProfile(profileData);
+ } else if (profileId) {
+ success = await updateProfile(profileId, profileData);
+ } else {
+ return;
+ }
+
+ if (success) {
+ toast.success(isNewProfile ? 'Profile created' : 'Profile updated');
+ onOpenChange(false);
+ } else {
+ toast.error(isNewProfile ? 'Failed to create profile' : 'Failed to update profile');
+ }
+ } catch (error) {
+ console.error('Error saving profile:', error);
+ toast.error('An error occurred while saving');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleConfirmDelete = async () => {
+ if (!profileId || isNewProfile) return;
+ setIsDeleting(true);
+ try {
+ const success = await deleteProfile(profileId);
+ if (success) {
+ toast.success('Profile deleted');
+ setIsDeleteDialogOpen(false);
+ onOpenChange(false);
+ } else {
+ toast.error('Failed to delete profile');
+ }
+ } catch (error) {
+ console.error('Error deleting profile:', error);
+ toast.error('An error occurred while deleting');
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const currentColorValue = React.useMemo(() => {
+ const colorConfig = PROFILE_COLORS.find(c => c.key === color);
+ return colorConfig?.cssVar || 'var(--syntax-keyword)';
+ }, [color]);
+
+ const title = importData
+ ? 'Import Credential'
+ : isNewProfile
+ ? 'New Identity'
+ : isGlobalProfile
+ ? 'Global Identity'
+ : (selectedProfile?.name || 'Edit Identity');
+
+ return (
+ <>
+
+
+
+ {title}
+
+ {isGlobalProfile
+ ? 'System-wide Git identity (read-only)'
+ : isNewProfile
+ ? 'Create a new Git identity profile'
+ : 'Edit identity profile settings'}
+
+
+
+
+ {/* Profile Display */}
+ {!isGlobalProfile && (
+
+
+ Profile Name
+ setName(e.target.value)}
+ placeholder="Work Profile, Personal, etc."
+ className="h-8"
+ />
+
+
+
+
Color
+
+ {PROFILE_COLORS.map((c) => (
+ setColor(c.key)}
+ className={cn(
+ 'w-6 h-6 rounded-md border-2 transition-all cursor-pointer',
+ color === c.key
+ ? 'border-foreground scale-110'
+ : 'border-transparent hover:border-border'
+ )}
+ style={{ backgroundColor: c.cssVar }}
+ title={c.label}
+ />
+ ))}
+
+
+
+
+
Icon
+
+ {PROFILE_ICONS.map((i) => {
+ const IconComponent = i.Icon;
+ return (
+ setIcon(i.key)}
+ className={cn(
+ 'w-7 h-7 rounded-md border-2 transition-all flex items-center justify-center cursor-pointer',
+ icon === i.key
+ ? 'border-[var(--interactive-border)] bg-[var(--surface-muted)]'
+ : 'border-transparent hover:border-[var(--interactive-border)] hover:bg-[var(--surface-muted)]/50'
+ )}
+ title={i.label}
+ >
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Separator */}
+ {!isGlobalProfile &&
}
+
+ {/* Git Author */}
+
+
+
+ User Name
+ {!isGlobalProfile && * }
+
+
+
+
+
+ The name that will appear in Git commit messages.
+
+
+
+
setUserName(e.target.value)}
+ placeholder="John Doe"
+ required={!isGlobalProfile}
+ readOnly={isGlobalProfile}
+ disabled={isGlobalProfile}
+ className="h-8"
+ />
+
+
+
+
+ Email Address
+ {!isGlobalProfile && * }
+
+
+
+
+
+ Should match your email in GitHub/GitLab for proper attribution.
+
+
+
+
setUserEmail(e.target.value)}
+ placeholder="john@example.com"
+ required={!isGlobalProfile}
+ readOnly={isGlobalProfile}
+ disabled={isGlobalProfile}
+ className="h-8"
+ />
+
+
+
+ {/* Authentication */}
+ {!isGlobalProfile && (
+ <>
+
+
+
+
Auth Method
+
+ setAuthType('ssh')}
+ className={cn(
+ authType === 'ssh'
+ ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
+ : 'text-foreground'
+ )}
+ >
+ SSH
+
+ setAuthType('token')}
+ className={cn(
+ authType === 'token'
+ ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
+ : 'text-foreground'
+ )}
+ >
+ Token
+
+
+
+
+ {authType === 'ssh' && (
+
+
+ SSH Key Path
+
+
+
+
+
+ Optional path to private key. e.g. ~/.ssh/id_ed25519
+
+
+
+
setSshKey(e.target.value)}
+ placeholder="~/.ssh/id_ed25519"
+ className="h-8 font-mono text-xs"
+ />
+
+ )}
+
+ {authType === 'token' && (
+
+
+ Host
+ *
+
+
+
+
+
+ Token will be read from ~/.git-credentials for this host.
+
+
+
+
setHost(e.target.value)}
+ placeholder="github.com"
+ required
+ className="h-8 font-mono text-xs"
+ />
+
+ )}
+
+ >
+ )}
+
+
+
+ {!isGlobalProfile && !isNewProfile && (
+ setIsDeleteDialogOpen(true)}
+ className="text-[var(--status-error)] hover:text-[var(--status-error)] border-[var(--status-error)]/30 hover:bg-[var(--status-error)]/10 mr-auto"
+ >
+ Delete
+
+ )}
+ onOpenChange(false)} className="text-foreground hover:bg-interactive-hover hover:text-foreground">
+ {isGlobalProfile ? 'Close' : 'Cancel'}
+
+ {!isGlobalProfile && (
+
+ {isSaving ? 'Saving...' : isNewProfile ? 'Create' : 'Save'}
+
+ )}
+
+
+
+
+ {/* Delete confirmation */}
+ { if (!isDeleting) setIsDeleteDialogOpen(o); }}
+ >
+
+
+ Delete Profile
+
+ Are you sure you want to delete "{selectedProfile?.name || name}"?
+
+
+
+ setIsDeleteDialogOpen(false)} disabled={isDeleting}>
+ Cancel
+
+ void handleConfirmDelete()} disabled={isDeleting} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
+ Delete
+
+
+
+
+ >
+ );
+};
diff --git a/packages/ui/src/components/sections/git-identities/GitPage.tsx b/packages/ui/src/components/sections/git-identities/GitPage.tsx
new file mode 100644
index 00000000..5cb7b1e9
--- /dev/null
+++ b/packages/ui/src/components/sections/git-identities/GitPage.tsx
@@ -0,0 +1,349 @@
+import React from 'react';
+import { ButtonSmall } from '@/components/ui/button-small';
+import { toast } from '@/components/ui';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ButtonLarge } from '@/components/ui/button-large';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ RiAddLine,
+ RiGitBranchLine,
+ RiBriefcaseLine,
+ RiHomeLine,
+ RiGraduationCapLine,
+ RiCodeLine,
+ RiHeartLine,
+ RiMore2Line,
+ RiDeleteBinLine,
+ RiDownloadLine,
+ RiShieldKeyholeLine,
+} from '@remixicon/react';
+import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
+import { GitSettings } from '@/components/sections/openchamber/GitSettings';
+import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
+import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { cn } from '@/lib/utils';
+
+const ICON_MAP: Record> = {
+ branch: RiGitBranchLine,
+ briefcase: RiBriefcaseLine,
+ house: RiHomeLine,
+ graduation: RiGraduationCapLine,
+ code: RiCodeLine,
+ heart: RiHeartLine,
+};
+
+const COLOR_MAP: Record = {
+ keyword: 'var(--syntax-keyword)',
+ error: 'var(--status-error)',
+ string: 'var(--syntax-string)',
+ function: 'var(--syntax-function)',
+ type: 'var(--syntax-type)',
+};
+
+export const GitPage: React.FC = () => {
+ const {
+ profiles,
+ globalIdentity,
+ defaultGitIdentityId,
+ deleteProfile,
+ loadProfiles,
+ loadGlobalIdentity,
+ loadDiscoveredCredentials,
+ loadDefaultGitIdentityId,
+ setDefaultGitIdentityId,
+ getUnimportedCredentials,
+ } = useGitIdentitiesStore();
+
+ const [editorOpen, setEditorOpen] = React.useState(false);
+ const [editorProfileId, setEditorProfileId] = React.useState(null);
+ const [editorImportData, setEditorImportData] = React.useState<{ host: string; username: string } | null>(null);
+ const [deleteDialogProfile, setDeleteDialogProfile] = React.useState(null);
+ const [isDeletePending, setIsDeletePending] = React.useState(false);
+
+ React.useEffect(() => {
+ loadProfiles();
+ loadGlobalIdentity();
+ loadDiscoveredCredentials();
+ loadDefaultGitIdentityId();
+ }, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]);
+
+ const unimportedCredentials = getUnimportedCredentials();
+
+ const openEditor = (id: string | null, importData?: { host: string; username: string } | null) => {
+ setEditorProfileId(id);
+ setEditorImportData(importData ?? null);
+ setEditorOpen(true);
+ };
+
+ const handleToggleDefault = async (profileId: string) => {
+ const next = defaultGitIdentityId === profileId ? null : profileId;
+ const ok = await setDefaultGitIdentityId(next);
+ if (!ok) {
+ toast.error('Failed to update default identity');
+ return;
+ }
+ toast.success(next ? 'Default identity updated' : 'Default identity unset');
+ };
+
+ const handleConfirmDelete = async () => {
+ if (!deleteDialogProfile) return;
+ setIsDeletePending(true);
+ const success = await deleteProfile(deleteDialogProfile.id);
+ if (success) {
+ toast.success(`Profile "${deleteDialogProfile.name}" deleted`);
+ setDeleteDialogProfile(null);
+ } else {
+ toast.error('Failed to delete profile');
+ }
+ setIsDeletePending(false);
+ };
+
+ return (
+ <>
+
+
+
+
+ {/* Identities Section */}
+
+
+
+
Identities
+
+
openEditor('new')}>
+ New
+
+
+
+
+ {/* Global identity */}
+ {globalIdentity && (
+
openEditor('global')}
+ onToggleDefault={() => handleToggleDefault('global')}
+ isReadOnly
+ hasBorder={profiles.length > 0 || unimportedCredentials.length > 0}
+ />
+ )}
+
+ {/* Custom profiles */}
+ {profiles.map((profile, i) => (
+ openEditor(profile.id)}
+ onToggleDefault={() => handleToggleDefault(profile.id)}
+ onDelete={() => setDeleteDialogProfile(profile)}
+ hasBorder={i < profiles.length - 1 || unimportedCredentials.length > 0}
+ />
+ ))}
+
+ {/* Empty state */}
+ {!globalIdentity && profiles.length === 0 && unimportedCredentials.length === 0 && (
+
+
+
No identities configured
+
Create one to manage Git author settings per project
+
+ )}
+
+ {/* Discovered credentials */}
+ {unimportedCredentials.length > 0 && (
+ <>
+
+
+ Found in ~/.git-credentials
+
+
+ {unimportedCredentials.map((cred, i) => (
+ openEditor('new', { host: cred.host, username: cred.username })}
+ hasBorder={i < unimportedCredentials.length - 1}
+ />
+ ))}
+ >
+ )}
+
+
+
+
+
+
+
+ {/* Editor dialog */}
+
+
+ {/* Delete confirmation */}
+ { if (!isDeletePending) { if (!o) setDeleteDialogProfile(null); } }}
+ >
+
+
+ Delete Profile
+
+ Are you sure you want to delete "{deleteDialogProfile?.name}"?
+
+
+
+ setDeleteDialogProfile(null)} disabled={isDeletePending}>
+ Cancel
+
+ void handleConfirmDelete()} disabled={isDeletePending} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
+ Delete
+
+
+
+
+ >
+ );
+};
+
+// --- Identity row ---
+
+interface IdentityRowProps {
+ profile: GitIdentityProfile;
+ isDefault: boolean;
+ onEdit: () => void;
+ onToggleDefault: () => void;
+ onDelete?: () => void;
+ isReadOnly?: boolean;
+ hasBorder?: boolean;
+}
+
+const IdentityRow: React.FC = ({
+ profile,
+ isDefault,
+ onEdit,
+ onToggleDefault,
+ onDelete,
+ isReadOnly,
+ hasBorder,
+}) => {
+ const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
+ const iconColor = COLOR_MAP[profile.color || ''];
+ const authType = profile.authType || 'ssh';
+
+ return (
+ { if (e.key === 'Enter' || e.key === ' ') onEdit(); }}
+ >
+
+
+
+
+ {profile.name}
+
+ {authType}
+
+ {isDefault && (
+
+ default
+
+ )}
+ {isReadOnly && (
+
+ system
+
+ )}
+
+
+ {authType === 'token' && profile.host ? profile.host : profile.userEmail}
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+ { e.stopPropagation(); onToggleDefault(); }}>
+ {isDefault ? 'Unset default' : 'Set as default'}
+
+ {!isReadOnly && onDelete && (
+ { e.stopPropagation(); onDelete(); }}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+ )}
+
+
+
+ );
+};
+
+// --- Discovered credential row ---
+
+interface DiscoveredRowProps {
+ credential: DiscoveredGitCredential;
+ onImport: () => void;
+ hasBorder?: boolean;
+}
+
+const DiscoveredRow: React.FC = ({ credential, onImport, hasBorder }) => {
+ const parts = credential.host.split('/');
+ const displayName = parts.length >= 3 ? parts[parts.length - 1] : credential.host;
+ const isRepoSpecific = credential.host.includes('/');
+
+ return (
+
+
+ {displayName}
+
+ {isRepoSpecific ? credential.host : credential.username}
+
+
+
+
+ Import
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/mcp/McpPage.tsx b/packages/ui/src/components/sections/mcp/McpPage.tsx
index 97d99aaa..ee145fe4 100644
--- a/packages/ui/src/components/sections/mcp/McpPage.tsx
+++ b/packages/ui/src/components/sections/mcp/McpPage.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
+import { ButtonSmall } from '@/components/ui/button-small';
+import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
@@ -20,12 +22,10 @@ import {
RiEyeOffLine,
RiFolderLine,
RiPlugLine,
- RiSaveLine,
RiUser3Line,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
-import { ButtonSmall } from '@/components/ui/button-small';
import {
Dialog,
DialogContent,
@@ -109,21 +109,18 @@ const CommandTextarea: React.FC = ({ value, onChange }) =>
return (
-
-
- One argument per line. Blank lines are ignored.
-
-
+
Paste command
-
+
-
Paste .env
-
+
{/* Rows */}
@@ -302,28 +299,27 @@ const EnvEditor: React.FC
= ({ value, onChange }) => {
{/* Remove */}
- removeRow(idx)}
>
-
+
))}
-
Add variable
-
+
{hasSensitiveValues && (
@@ -345,16 +341,14 @@ const STATUS_LABEL: Record = {
};
const StatusBadge: React.FC<{ status: string | undefined; enabled: boolean }> = ({ status, enabled }) => {
- if (!enabled) {
- return Disabled ;
- }
+ if (!enabled) return null;
if (!status) return null;
const colorMap: Record = {
- connected: 'text-green-600 dark:text-green-400',
- failed: 'text-destructive',
- needs_auth: 'text-yellow-600 dark:text-yellow-400',
- needs_client_registration: 'text-yellow-600 dark:text-yellow-400',
+ connected: 'text-[var(--status-success)]',
+ failed: 'text-[var(--status-error)]',
+ needs_auth: 'text-[var(--status-warning)]',
+ needs_client_registration: 'text-[var(--status-warning)]',
};
return (
@@ -523,195 +517,200 @@ export const McpPage: React.FC = () => {
return (
-
+
- {/* ── Header card: name + status + enabled + connect ── */}
-
-
- {/* Row 1: name + connect button */}
-
-
- {isNewServer ? (
-
setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
- placeholder="my-mcp-server"
- className="font-mono text-base h-8 w-64"
- autoFocus
- />
- ) : (
-
{selectedMcpName}
- )}
- {isNewServer && (
-
- Lowercase, numbers, hyphens and underscores only
-
+ {/* Header */}
+
+
+ {isNewServer ? (
+
New MCP Server
+ ) : (
+
+
{selectedMcpName}
+
+
+ )}
+
+
+ {isNewServer ? 'Configure a new MCP server' : `${mcpType === 'local' ? 'Local · stdio' : 'Remote · SSE'} transport`}
+
+ {!isNewServer && (
+
+ {isConnecting ? 'Working...' : isConnected ? 'Disconnect' : 'Connect'}
+
)}
-
- {isNewServer && (
-
setDraftScope(value as McpScope)}>
-
- {draftScope === 'user' ? (
-
- ) : (
-
- )}
- {draftScope}
-
-
-
-
-
-
- User
-
-
Available in all projects
-
-
-
-
-
-
- Project
-
-
Only in current project
-
-
-
-
- )}
-
- {!isNewServer && (
-
- {isConnecting ? 'Working…' : isConnected ? 'Disconnect' : 'Connect'}
-
- )}
-
- {/* Row 2: status + type badge + enabled toggle */}
-
-
-
- ·
-
- {mcpType === 'local' ? 'stdio' : 'remote'}
-
-
-
- {/* Enabled toggle */}
-
-
- {enabled ? 'Enabled' : 'Disabled'}
-
- setEnabled(!enabled)}
- className={cn(
- 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
- enabled ? 'bg-primary' : 'bg-muted',
- )}
- >
-
-
-
-
-
- {/* Row 3: type selector — always visible so user can switch type */}
-
- setMcpType('local')}
- className={cn(mcpType !== 'local' && 'text-foreground')}
- >
- Local · stdio
-
- setMcpType('remote')}
- className={cn(mcpType !== 'remote' && 'text-foreground')}
- >
- Remote · SSE
-
-
- {/* ── Connection ── */}
-
- {mcpType === 'local' ? (
- <>
-
- Command
-
+ {/* Server Identity */}
+
+
+
Server
+
+
+
+
+ {isNewServer && (
+
+
+ Server Name
+
+
+
setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
+ placeholder="my-mcp-server"
+ className="h-7 w-48 font-mono px-2"
+ autoFocus
+ />
+
setDraftScope(value as McpScope)}>
+
+ {draftScope === 'user' ? : }
+
+
+
+
+
+ User
+
+
+
+
+
+ Project
+
+
+
+
+
+
+ )}
+
+ setEnabled(!enabled)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setEnabled(!enabled);
+ }
+ }}
+ >
+
+ Enable Server
+
+
+
+
+
Transport Mode
+
+ setMcpType('local')}
+ className={cn(
+ '!font-normal',
+ mcpType === 'local'
+ ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
+ : 'text-foreground'
+ )}
+ >
+ Local · stdio
+
+ setMcpType('remote')}
+ className={cn(
+ '!font-normal',
+ mcpType === 'remote'
+ ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
+ : 'text-foreground'
+ )}
+ >
+ Remote · SSE
+
+
+
+
+
+
+
+
+ {/* Connection */}
+
+
+
+ {mcpType === 'local' ? 'Command' : 'Server URL'}
+
+
+
+
- {/* ── Environment Variables ── */}
-
-
-
+ {/* Environment Variables */}
+
+
+
Environment Variables
{envEntries.length > 0 && (
({envEntries.length})
)}
-
+
-
+
+
- {/* ── Actions ── */}
-
- {!isNewServer ? (
-
setShowDeleteConfirm(true)}
- className="h-7 gap-1.5 typography-meta text-destructive hover:text-destructive hover:bg-destructive/10"
- >
-
- Delete
-
- ) :
}
-
-
+
-
- {isSaving ? 'Saving…' : isNewServer ? 'Create' : 'Save changes'}
-
+ {isSaving ? 'Saving...' : isNewServer ? 'Create' : 'Save Changes'}
+
+ {!isNewServer && (
+
setShowDeleteConfirm(true)}
+ >
+ Delete
+
+ )}
diff --git a/packages/ui/src/components/sections/mcp/McpSidebar.tsx b/packages/ui/src/components/sections/mcp/McpSidebar.tsx
index 5393c03c..c852922f 100644
--- a/packages/ui/src/components/sections/mcp/McpSidebar.tsx
+++ b/packages/ui/src/components/sections/mcp/McpSidebar.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { Button } from '@/components/ui/button';
+import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large';
import {
Dialog,
@@ -9,14 +9,15 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine, RiServerLine } from '@remixicon/react';
+import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine } from '@remixicon/react';
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
-import { isVSCodeRuntime } from '@/lib/desktop';
+import { isMobileDeviceViaCSS } from '@/lib/device';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import {
DropdownMenu,
DropdownMenuContent,
@@ -48,9 +49,9 @@ const StatusDot: React.FC<{ tone: StatusTone; enabled: boolean }> = ({ tone, ena
);
}
const classes: Record
= {
- success: 'bg-green-500',
- error: 'bg-destructive',
- warning: 'bg-yellow-500',
+ success: 'bg-[var(--status-success)]',
+ error: 'bg-[var(--status-error)]',
+ warning: 'bg-[var(--status-warning)]',
idle: 'bg-muted-foreground/40',
};
return (
@@ -59,8 +60,7 @@ const StatusDot: React.FC<{ tone: StatusTone; enabled: boolean }> = ({ tone, ena
};
export const McpSidebar: React.FC = ({ onItemSelect }) => {
- const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
- const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
+ const bgClass = 'bg-background';
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
useMcpConfigStore();
@@ -70,6 +70,16 @@ export const McpSidebar: React.FC = ({ onItemSelect }) => {
const [deleteTarget, setDeleteTarget] = React.useState(null);
const [isDeleting, setIsDeleting] = React.useState(false);
+ const [openMenuMcp, setOpenMenuMcp] = React.useState(null);
+
+ const projectServers = React.useMemo(
+ () => mcpServers.filter((server) => server.scope === 'project'),
+ [mcpServers]
+ );
+ const userServers = React.useMemo(
+ () => mcpServers.filter((server) => server.scope !== 'project'),
+ [mcpServers]
+ );
React.useEffect(() => {
void loadMcpConfigs();
@@ -113,22 +123,21 @@ export const McpSidebar: React.FC = ({ onItemSelect }) => {
return (
- {/* Header */}
-
+
+
MCP Servers
+
- {mcpServers.length} server{mcpServers.length !== 1 ? 's' : ''}
+ Total {mcpServers.length}
-
-
-
+
+
@@ -141,74 +150,147 @@ export const McpSidebar: React.FC
= ({ onItemSelect }) => {
Use the + button above to add one
) : (
- mcpServers.map((server) => {
- const runtimeStatus = mcpStatus[server.name];
- const tone = statusToneFromMcp(runtimeStatus?.status);
- const isSelected = selectedMcpName === server.name;
+ <>
+ {projectServers.length > 0 && (
+ <>
+
+ Project Servers
+
+ {projectServers.map((server) => {
+ const runtimeStatus = mcpStatus[server.name];
+ const tone = statusToneFromMcp(runtimeStatus?.status);
+ const isSelected = selectedMcpName === server.name;
+ const isMobile = isMobileDeviceViaCSS();
- return (
-
-
{
- setSelectedMcp(server.name);
- setMcpDraft(null);
- onItemSelect?.();
- }}
- className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
- >
-
-
-
- {server.name}
-
-
- {server.type}
-
- {!server.enabled && (
-
- off
-
- )}
-
-
- {server.type === 'local'
- ? (server as { command?: string[] }).command?.join(' ') ?? ''
- : (server as { url?: string }).url ?? ''}
-
-
+ return (
+
{
+ e.preventDefault();
+ setOpenMenuMcp(server.name);
+ } : undefined}
+ >
+
{
+ setSelectedMcp(server.name);
+ setMcpDraft(null);
+ onItemSelect?.();
+ }}
+ className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
+ >
+
+
+ {server.name}
+
+ {server.type}
+
+
+
+ {server.type === 'local'
+ ? (server as { command?: string[] }).command?.join(' ') ?? ''
+ : (server as { url?: string }).url ?? ''}
+
+
-
-
- setOpenMenuMcp(open ? server.name : null)}>
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ setDeleteTarget(server);
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+
+
+
+ );
+ })}
+ >
+ )}
+
+ {userServers.length > 0 && (
+ <>
+
+ User Servers
+
+ {userServers.map((server) => {
+ const runtimeStatus = mcpStatus[server.name];
+ const tone = statusToneFromMcp(runtimeStatus?.status);
+ const isSelected = selectedMcpName === server.name;
+ const isMobile = isMobileDeviceViaCSS();
+
+ return (
+
{
+ e.preventDefault();
+ setOpenMenuMcp(server.name);
+ } : undefined}
>
-
-
-
-
- {
- e.stopPropagation();
- setDeleteTarget(server);
- }}
- className="text-destructive focus:text-destructive"
- >
-
- Delete
-
-
-
-
- );
- })
+
{
+ setSelectedMcp(server.name);
+ setMcpDraft(null);
+ onItemSelect?.();
+ }}
+ className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
+ >
+
+
+ {server.name}
+
+ {server.type}
+
+
+
+ {server.type === 'local'
+ ? (server as { command?: string[] }).command?.join(' ') ?? ''
+ : (server as { url?: string }).url ?? ''}
+
+
+
+
setOpenMenuMcp(open ? server.name : null)}>
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ setDeleteTarget(server);
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+
+
+
+ );
+ })}
+ >
+ )}
+ >
)}
@@ -226,14 +308,13 @@ export const McpSidebar: React.FC
= ({ onItemSelect }) => {
- setDeleteTarget(null)}
disabled={isDeleting}
- className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
-
+
{isDeleting ? 'Deleting…' : 'Delete'}
@@ -245,4 +326,4 @@ export const McpSidebar: React.FC = ({ onItemSelect }) => {
};
// Re-export for easy sidebar icon usage
-export { RiServerLine as McpIcon };
+export { McpIcon } from '@/components/icons/McpIcon';
diff --git a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx
index 47bf10a2..5b61159e 100644
--- a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx
@@ -5,6 +5,7 @@ import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { useDeviceInfo } from '@/lib/device';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
+import { ButtonSmall } from '@/components/ui/button-small';
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
@@ -67,7 +68,7 @@ export const AboutSettings: React.FC = () => {
{!isChecking && updateStore.available && (
setUpdateDialogOpen(true)}
- className="flex items-center gap-1 typography-meta text-primary hover:underline"
+ className="flex items-center gap-1 typography-meta text-[var(--primary-base)] hover:underline"
>
Update
@@ -76,7 +77,7 @@ export const AboutSettings: React.FC = () => {
{updateStore.error && (
- {updateStore.error}
+ {updateStore.error}
)}
{/* Links row */}
@@ -129,99 +130,83 @@ export const AboutSettings: React.FC = () => {
}
- // Desktop layout (unchanged)
+ // Desktop layout (redesigned)
return (
-
-
+
+
About OpenChamber
- {/* Version and Update */}
-
-
-
-
Version
-
{currentVersion}
+
+
+
+ Version
+ {currentVersion}
+
+
+ {updateStore.checking && (
+
+
+ Checking...
+
+ )}
- {updateStore.checking && (
-
-
- Checking...
-
- )}
+ {!updateStore.checking && updateStore.available && (
+
setUpdateDialogOpen(true)}
+ >
+
+ Update to {updateStore.info?.version}
+
+ )}
- {!updateStore.checking && updateStore.available && (
-
setUpdateDialogOpen(true)}
- className={cn(
- 'flex items-center gap-2 px-3 py-1.5 rounded-md',
- 'text-sm font-medium',
- 'bg-primary text-primary-foreground',
- 'hover:bg-primary/90',
- 'transition-colors'
- )}
+ {!updateStore.checking && !updateStore.available && !updateStore.error && (
+ Up to date
+ )}
+
+ updateStore.checkForUpdates()}
+ disabled={updateStore.checking}
>
-
- Update to {updateStore.info?.version}
-
- )}
-
- {!updateStore.checking && !updateStore.available && !updateStore.error && (
-
Up to date
- )}
+ Check for updates
+
+
-
+
{updateStore.error && (
-
{updateStore.error}
+
)}
-
updateStore.checkForUpdates()}
- disabled={updateStore.checking}
- className={cn(
- 'typography-meta text-muted-foreground hover:text-foreground',
- 'underline-offset-2 hover:underline',
- 'disabled:opacity-50 disabled:cursor-not-allowed'
- )}
- >
- Check for updates
-
+
- {/* Links */}
- {/* Links */}
-
-
- {/* Update Dialog */}
}>
+ storedModel: string | undefined
): { providerId: string; modelId: string } => {
if (storedModel) {
const parts = storedModel.split('/');
@@ -30,16 +27,8 @@ const getDisplayModel = (
}
}
- const fallbackProvider = providers.find(p => p.id === FALLBACK_PROVIDER_ID);
- if (fallbackProvider?.models.some(m => m.id === FALLBACK_MODEL_ID)) {
- return { providerId: FALLBACK_PROVIDER_ID, modelId: FALLBACK_MODEL_ID };
- }
-
- const firstProvider = providers[0];
- if (firstProvider?.models[0]) {
- return { providerId: firstProvider.id, modelId: firstProvider.models[0].id };
- }
-
+ // Return empty values when no model is explicitly set
+ // This allows showing "Not selected" instead of a fallback
return { providerId: '', modelId: '' };
};
@@ -55,6 +44,8 @@ export const DefaultsSettings: React.FC = () => {
const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree);
const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel);
+ const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
+ const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const providers = useConfigStore((state) => state.providers);
const [defaultModel, setDefaultModel] = React.useState();
@@ -65,8 +56,8 @@ export const DefaultsSettings: React.FC = () => {
const [zenModelsLoading, setZenModelsLoading] = React.useState(true);
const parsedModel = React.useMemo(() => {
- return getDisplayModel(defaultModel, providers);
- }, [defaultModel, providers]);
+ return getDisplayModel(defaultModel);
+ }, [defaultModel]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
@@ -275,8 +266,7 @@ export const DefaultsSettings: React.FC = () => {
}
}, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]);
- const handleAutoWorktreeChange = React.useCallback(async (e: React.ChangeEvent) => {
- const enabled = e.target.checked;
+ const handleAutoWorktreeChange = React.useCallback(async (enabled: boolean) => {
setSettingsAutoCreateWorktree(enabled);
try {
await updateDesktopSettings({
@@ -303,133 +293,178 @@ export const DefaultsSettings: React.FC = () => {
}
return (
-
-
+
+
-
Session Defaults
-
-
-
-
-
- Configure default behaviors for new sessions.
-
-
+ Session Defaults
-
-
- Default model
-
-
-
- {supportsVariants && (
-
- Default thinking
-
-
-
-
-
- Default
- {availableVariants.map((variant) => (
-
- {variant}
-
- ))}
-
-
-
- )}
-
-
-
-
- {(parsedModel.providerId || defaultAgent) && (
-
+
+
New sessions will start with:{' '}
- {parsedModel.providerId && (
+ {parsedModel.providerId ? (
{parsedModel.providerId}/{parsedModel.modelId}
{supportsVariants ? ` (${defaultVariant ?? 'default'})` : ''}
+ ) : (
+ opencode agent default
+ )}
+ {defaultAgent && (
+ <>
+ {' / '}
+ {defaultAgent}
+ >
)}
- {parsedModel.providerId && defaultAgent && ' / '}
- {defaultAgent && {defaultAgent} }
- )}
+
+
+ Default Model
+
+
+
+
+
- {!isVSCode && (
-
-
+
+
+ Default Thinking
+
+
+
+
+
+
+
+ Default
+ {availableVariants.map((variant) => (
+
+ {variant}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ Zen Model
+
+
+
+
+
+ The free model used for lightweight internal tasks like commit message generation, PR descriptions, notification summarization, and TTS text summarization.
+
+
+
+
+
+ {zenModelsLoading ? (
+ Loading models...
+ ) : zenModels.length > 0 ? (
+
+
+
+
+
+ {zenModels.map((model) => (
+
+ {model.id}
+
+ ))}
+
+
+ ) : (
+ No free models available
+ )}
+
+
+
+ setShowDeletionDialog(!showDeletionDialog)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setShowDeletionDialog(!showDeletionDialog);
+ }
+ }}
+ >
+
+ Show Deletion Dialog
+
+
+ {!isVSCode && (
+ {
+ void handleAutoWorktreeChange(!settingsAutoCreateWorktree);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ void handleAutoWorktreeChange(!settingsAutoCreateWorktree);
+ }
+ }}
+ >
handleAutoWorktreeChange({ target: { checked } } as React.ChangeEvent)}
+ onChange={(checked) => {
+ void handleAutoWorktreeChange(checked);
+ }}
+ ariaLabel="Always create worktree"
/>
-
- Always create worktree for new sessions
-
-
-
- {settingsAutoCreateWorktree
- ? `New session (Worktree): ${getModifierLabel()} + N • New session (Standard): Shift + ${getModifierLabel()} + N`
- : `New session (Standard): ${getModifierLabel()} + N • New session (Worktree): Shift + ${getModifierLabel()} + N`}
-
-
- )}
-
-
-
-
-
Zen Model
-
-
-
-
-
- The free model used for lightweight internal tasks like commit message generation, PR descriptions, notification summarization, and TTS text summarization.
-
-
+
+
+ Always Create Worktree
+
+
+
+
+
+ {settingsAutoCreateWorktree
+ ? `New session (Worktree): ${getModifierLabel()}+N\nStandard: Shift+${getModifierLabel()}+N`
+ : `New session (Standard): ${getModifierLabel()}+N\nWorktree: Shift+${getModifierLabel()}+N`}
+
+
+
+
-
- Used for commit messages, PR descriptions, and text summarization.
-
-
+ )}
-
- Model
- {zenModelsLoading ? (
- Loading models...
- ) : zenModels.length > 0 ? (
-
-
-
-
-
- {zenModels.map((model) => (
-
- {model.id}
-
- ))}
-
-
- ) : (
- No free models available
- )}
-
-
+
+
);
};
diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
index 626fc6fb..3442ce73 100644
--- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
@@ -1,10 +1,14 @@
import React from 'react';
import { Button } from '@/components/ui/button';
+import { ButtonSmall } from '@/components/ui/button-small';
import { toast } from '@/components/ui';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { GitHubAuthStatus } from '@/lib/api/types';
-import { RiGithubFill } from '@remixicon/react';
+import { useDeviceInfo } from '@/lib/device';
+import { cn } from '@/lib/utils';
+import { RiGithubFill, RiInformationLine } from '@remixicon/react';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
type GitHubUser = {
login: string;
@@ -29,6 +33,7 @@ type DeviceFlowCompleteResponse =
| { connected: false; status?: string; error?: string };
export const GitHubSettings: React.FC = () => {
+ const { isMobile } = useDeviceInfo();
const runtimeGitHub = getRegisteredRuntimeAPIs()?.github;
const status = useGitHubAuthStore((state) => state.status);
const isLoading = useGitHubAuthStore((state) => state.isLoading);
@@ -252,134 +257,149 @@ export const GitHubSettings: React.FC = () => {
const accounts = status?.accounts ?? [];
return (
-
-
-
GitHub
-
- Connect a GitHub account for in-app PR and issue workflows.
-
+
+
+
+
GitHub
+
+
+
+
+
+ Connect a GitHub account for in-app PR and issue workflows.
+
+
+
- {connected ? (
-
-
- {user?.avatarUrl ? (
-
- ) : (
-
- )}
+
+ {connected ? (
+
+
+ {user?.avatarUrl ? (
+
+ ) : (
+
+ )}
-
-
- {user?.name?.trim() || user?.login || 'GitHub'}
+
+
+ {user?.name?.trim() || user?.login || 'GitHub'}
+
+
+
+ {user?.login || 'unknown'}
+ {user?.email && • }
+ {user?.email && {user.email} }
+
+ {status?.scope && (
+
Scopes: {status.scope}
+ )}
- {user?.email ? (
-
{user.email}
- ) : null}
-
-
- {user?.login || 'unknown'}
-
- {status?.scope ? (
-
Scopes: {status.scope}
- ) : null}
+
+
+
+ Disconnect
+
+
+ ) : (
+
+
+ Not Connected
+
+
+ Connect GitHub
+
+
+ )}
+
+ {accounts.length > 1 && (
+
+
Other Accounts
+
+ {accounts.map((account) => {
+ const accountUser = account.user;
+ const isCurrent = Boolean(account.current);
+ return (
+
+
+ {accountUser?.avatarUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
+
+ {accountUser?.login && (
+
+ {accountUser.login}
+
+ )}
+
+
+ {isCurrent ? (
+
Active
+ ) : (
+
activateAccount(account.id)}
+ disabled={isBusy}
+ >
+ Switch to
+
+ )}
+
+ );
+ })}
+ )}
-
- Disconnect
-
-
- ) : (
-
-
Not connected
-
- Connect
-
+
+
+ {connected && (
+
+
+ Add Account
+
)}
- {connected ? (
-
-
- Add account
-
-
- ) : null}
-
- {accounts.length > 1 ? (
-
-
Accounts
-
- {accounts.map((account) => {
- const accountUser = account.user;
- const isCurrent = Boolean(account.current);
- return (
-
-
- {accountUser?.avatarUrl ? (
-
- ) : (
-
-
-
- )}
-
-
- {accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
-
- {accountUser?.login ? (
-
- {accountUser.login}
-
- ) : null}
-
-
- {isCurrent ? (
-
Active
- ) : (
-
activateAccount(account.id)}
- disabled={isBusy}
- >
- Use
-
- )}
-
- );
- })}
-
-
- ) : null}
-
- {flow ? (
-
+ {flow && (
+
-
Authorize OpenChamber
-
- In GitHub, enter this code:
-
+
Authorize OpenChamber
+
+ In GitHub, enter the following code to authorize this device:
+
-
-
{flow.userCode}
-
+
-
- Waiting for approval… (auto-refresh)
-
-
-
{
+
+
+ Waiting for approval… (auto-refresh)
+
+ {
stopPolling();
setFlow(null);
}}>
Cancel
-
+
- ) : null}
+ )}
);
};
diff --git a/packages/ui/src/components/sections/openchamber/GitSettings.tsx b/packages/ui/src/components/sections/openchamber/GitSettings.tsx
index 5f2ee812..dfa7179c 100644
--- a/packages/ui/src/components/sections/openchamber/GitSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/GitSettings.tsx
@@ -1,6 +1,4 @@
import React from 'react';
-import { RiInformationLine } from '@remixicon/react';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Checkbox } from '@/components/ui/checkbox';
import { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -14,7 +12,6 @@ export const GitSettings: React.FC = () => {
const [isLoading, setIsLoading] = React.useState(true);
-
// Load current settings
React.useEffect(() => {
const loadSettings = async () => {
@@ -67,8 +64,7 @@ export const GitSettings: React.FC = () => {
loadSettings();
}, [setSettingsGitmojiEnabled]);
- const handleGitmojiChange = React.useCallback(async (event: React.ChangeEvent
) => {
- const enabled = event.target.checked;
+ const handleGitmojiChange = React.useCallback(async (enabled: boolean) => {
setSettingsGitmojiEnabled(enabled);
try {
await updateDesktopSettings({
@@ -84,56 +80,58 @@ export const GitSettings: React.FC = () => {
}
return (
-
-
-
-
Commit Messages
-
-
-
-
-
- Configure how commit messages are generated.
-
-
-
+
+
+
Git Preferences
-
-
-
- handleGitmojiChange({ target: { checked } } as React.ChangeEvent)}
- />
- Enable gitmoji picker
-
-
- Adds a gitmoji selector to the Git commit message input.
-
+
+ {
+ void handleGitmojiChange(!settingsGitmojiEnabled);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ void handleGitmojiChange(!settingsGitmojiEnabled);
+ }
+ }}
+ >
+ {
+ void handleGitmojiChange(checked);
+ }}
+ ariaLabel="Enable Gitmoji picker"
+ />
+ Enable Gitmoji Picker
-
-
-
Files Overview
-
- Show gitignored files in the Files browser pane only.
-
-
-
-
-
-
- Display gitignored files
-
-
- Toggles gitignored files in the Files tree and search results.
-
+
setFilesViewShowGitignored(!showGitignored)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setFilesViewShowGitignored(!showGitignored);
+ }
+ }}
+ >
+
+ Display Gitignored Files
-
+
);
};
diff --git a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx
index 70e1c6a1..cd0db75d 100644
--- a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx
@@ -1,7 +1,10 @@
import React from 'react';
-import { Button } from '@/components/ui/button';
+import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { RiInformationLine } from '@remixicon/react';
import { useUIStore } from '@/stores/useUIStore';
+import { cn } from '@/lib/utils';
import {
formatShortcutForDisplay,
getCustomizableShortcutActions,
@@ -125,137 +128,136 @@ export const KeyboardShortcutsSettings: React.FC = () => {
}, [clearShortcutOverride]);
return (
-
-
-
Keyboard Shortcuts
-
- Capture a new key combo, save it, and the runtime/help/palette bindings update together.
-
+
+
+
+
Keyboard Shortcuts
+ {
+ resetAllShortcutOverrides();
+ setDraftByAction({});
+ setPendingOverwrite(null);
+ setErrorText('');
+ setWarningText('');
+ }}
+ >
+ Reset All
+
+
+
+
+
+
+ Capture a new key combo, save it, and bindings will update immediately.
+
+
+
-
- {actions.map((action) => {
+ {(errorText || warningText || pendingOverwrite) && (
+
+ {pendingOverwrite && (
+
+
+ This combo is already used by another shortcut. Overwrite and clear that other mapping?
+
+
+ Overwrite
+ setPendingOverwrite(null)}>Cancel
+
+
+ )}
+ {errorText && (
+
+ {errorText}
+
+ )}
+ {warningText && (
+
+ {warningText}
+
+ )}
+
+ )}
+
+
+ {actions.map((action, index) => {
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
const draft = draftByAction[action.id];
const displayCombo = draft ?? effective;
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
return (
-
-
-
-
{action.label}
- {action.description && (
-
{action.description}
- )}
-
-
-
{
- setCapturingActionId(action.id);
- setErrorText('');
- }}
- onBlur={() => {
- if (capturingActionId === action.id) {
- setCapturingActionId(null);
- }
- }}
- onKeyDown={(event) => {
- event.preventDefault();
- event.stopPropagation();
-
- if (event.key === 'Escape') {
- setCapturingActionId(null);
- return;
- }
-
- const combo = keyboardEventToCombo(event);
- if (!combo) {
- return;
- }
-
- setDraftByAction((current) => ({
- ...current,
- [action.id]: combo,
- }));
+
0 && "border-t border-[var(--surface-subtle)]")}>
+
+ {action.label}
+
+
+ {
+ setCapturingActionId(action.id);
+ setErrorText('');
+ }}
+ onBlur={() => {
+ if (capturingActionId === action.id) {
setCapturingActionId(null);
- setPendingOverwrite(null);
- setErrorText('');
- }}
- className="w-52"
- />
- {
- const next = draftByAction[action.id];
- if (!next) {
- setErrorText('Capture a shortcut first.');
- return;
- }
- saveCombo(action.id, next);
- }}
- disabled={!hasDraft}
- >
- Save
-
- resetOne(action.id)}>
- Reset
-
-
+ }
+ }}
+ onKeyDown={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (event.key === 'Escape') {
+ setCapturingActionId(null);
+ return;
+ }
+
+ const combo = keyboardEventToCombo(event);
+ if (!combo) {
+ return;
+ }
+
+ setDraftByAction((current) => ({
+ ...current,
+ [action.id]: combo,
+ }));
+ setCapturingActionId(null);
+ setPendingOverwrite(null);
+ setErrorText('');
+ }}
+ className="h-7 w-40 min-w-0 typography-ui-label text-center"
+ />
+
{
+ const next = draftByAction[action.id];
+ if (!next) {
+ setErrorText('Capture a shortcut first.');
+ return;
+ }
+ saveCombo(action.id, next);
+ }}
+ disabled={!hasDraft}
+ >
+ Save
+
+
resetOne(action.id)}>
+ Reset
+
);
})}
-
-
- {pendingOverwrite && (
-
-
- This combo is already used by another shortcut. Overwrite and clear that other mapping?
-
-
- Overwrite
- setPendingOverwrite(null)}>Cancel
-
-
- )}
-
- {errorText && (
-
- {errorText}
-
- )}
-
- {warningText && (
-
- {warningText}
-
- )}
-
-
- {
- resetAllShortcutOverrides();
- setDraftByAction({});
- setPendingOverwrite(null);
- setErrorText('');
- setWarningText('');
- }}
- >
- Reset all shortcuts
-
-
+
);
};
diff --git a/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx b/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx
index fa00b850..fb355c4f 100644
--- a/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx
@@ -1,8 +1,8 @@
import React from 'react';
-import { RiInformationLine } from '@remixicon/react';
+import { RiInformationLine, RiRestartLine } from '@remixicon/react';
import { NumberInput } from '@/components/ui/number-input';
+import { ButtonSmall } from '@/components/ui/button-small';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import { useDeviceInfo } from '@/lib/device';
import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -12,8 +12,6 @@ const MIN_LIMIT = 10;
const MAX_LIMIT = 500;
export const MemoryLimitsSettings: React.FC = () => {
- const { isMobile } = useDeviceInfo();
-
const messageLimit = useUIStore((state) => state.messageLimit);
const setMessageLimit = useUIStore((state) => state.setMessageLimit);
@@ -80,98 +78,51 @@ export const MemoryLimitsSettings: React.FC = () => {
const isDefault = messageLimit === DEFAULT_MESSAGE_LIMIT;
return (
-
-
+
+
-
Message Memory
+ Message Memory
- How many messages to keep in view per session.
+ Limit how many messages are loaded per session in memory.
Older messages are available via "Load more". Background sessions are trimmed automatically.
-
-
-
-
- Message limit
- Messages loaded per session
-
-
- {!isDefault && (
- (default: {DEFAULT_MESSAGE_LIMIT})
- )}
- {isMobile ? (
-
- ) : (
-
- )}
-
+
+
+
+ Message Limit
+
+
+
+ handleChange(DEFAULT_MESSAGE_LIMIT)}
+ disabled={isDefault}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset message limit"
+ title="Reset"
+ >
+
+
-
+
);
};
-
-const MobileInput: React.FC<{ value: number; min: number; max: number; onChange: (v: number) => void }> = ({
- value,
- min,
- max,
- onChange,
-}) => {
- const [draft, setDraft] = React.useState(String(value));
-
- React.useEffect(() => {
- setDraft(String(value));
- }, [value]);
-
- const handleChange = React.useCallback((e: React.ChangeEvent
) => {
- const nextValue = e.target.value;
- setDraft(nextValue);
- if (nextValue.trim() === '') return;
- const parsed = Number(nextValue);
- if (!Number.isFinite(parsed)) return;
- onChange(Math.min(max, Math.max(min, Math.round(parsed))));
- }, [min, max, onChange]);
-
- const handleBlur = React.useCallback(() => {
- if (draft.trim() === '') {
- setDraft(String(value));
- return;
- }
- const parsed = Number(draft);
- if (!Number.isFinite(parsed)) {
- setDraft(String(value));
- return;
- }
- const clamped = Math.min(max, Math.max(min, Math.round(parsed)));
- onChange(clamped);
- setDraft(String(clamped));
- }, [draft, value, min, max, onChange]);
-
- return (
-
- );
-};
diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx
index e1c89f69..36e78072 100644
--- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx
@@ -1,11 +1,16 @@
import React from 'react';
+import { RiRestartLine } from '@remixicon/react';
import { useUIStore } from '@/stores/useUIStore';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
-import { Switch } from '@/components/ui/switch';
+import { useDeviceInfo } from '@/lib/device';
+import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
-
import { GridLoader } from '@/components/ui/grid-loader';
+import { Input } from '@/components/ui/input';
+import { NumberInput } from '@/components/ui/number-input';
+import { ButtonSmall } from '@/components/ui/button-small';
+import { cn } from '@/lib/utils';
const DEFAULT_NOTIFICATION_TEMPLATES = {
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
@@ -14,7 +19,12 @@ const DEFAULT_NOTIFICATION_TEMPLATES = {
subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
} as const;
+const DEFAULT_SUMMARY_THRESHOLD = 200;
+const DEFAULT_SUMMARY_LENGTH = 100;
+const DEFAULT_MAX_LAST_MESSAGE_LENGTH = 250;
+
export const NotificationSettings: React.FC = () => {
+ const { isMobile } = useDeviceInfo();
const isDesktop = React.useMemo(() => isDesktopShell(), []);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isBrowser = !isDesktop && !isVSCode;
@@ -205,8 +215,6 @@ export const NotificationSettings: React.FC = () => {
throw new Error('navigator.serviceWorker.register unavailable');
}
- // iOS Safari can throw non-sensical internal errors when unsupported options
- // are passed. Try no-options first, then add options progressively.
const attempts: Array<{ label: string; opts: RegistrationOptions | null }> = [
{ label: 'no-options', opts: null },
{ label: 'scope-root', opts: { scope: '/' } },
@@ -225,7 +233,6 @@ export const NotificationSettings: React.FC = () => {
return await withTimeout(promise, 10000, `Service worker registration timed out (${attempt.label})`);
} catch (error) {
lastError = error;
- // ignore
}
}
@@ -254,7 +261,6 @@ export const NotificationSettings: React.FC = () => {
return registered;
};
-
const formatUnknownError = (error: unknown) => {
const anyError = error as { name?: unknown; message?: unknown; stack?: unknown } | null;
const parts = [
@@ -325,25 +331,21 @@ export const NotificationSettings: React.FC = () => {
throw new Error('PushManager unavailable (requires installed PWA + iOS 16.4+)');
}
-
const subscription = existing ?? await withTimeout(
registration.pushManager.subscribe({
userVisibleOnly: true,
- // iOS Safari is picky here; pass Uint8Array (not ArrayBuffer).
applicationServerKey: base64UrlToUint8Array(key.publicKey),
}),
15000,
'Push subscription timed out'
);
-
const json = subscription.toJSON();
const keys = json.keys;
if (!json.endpoint || !keys?.p256dh || !keys.auth) {
throw new Error('Push subscription missing keys');
}
-
const ok = await withTimeout(
apis.push.subscribe({
endpoint: json.endpoint,
@@ -357,7 +359,6 @@ export const NotificationSettings: React.FC = () => {
'Push subscribe request timed out'
);
-
if (!ok?.ok) {
toast.error('Failed to enable background notifications');
return;
@@ -371,7 +372,6 @@ export const NotificationSettings: React.FC = () => {
toast.error('Failed to enable background notifications', {
description: formatted.summary,
});
-
} finally {
setPushBusy(false);
}
@@ -409,338 +409,377 @@ export const NotificationSettings: React.FC = () => {
};
return (
-
-
-
- When to notify
-
-
- Customize when notifications show up.
-
-
+
-
-
-
- Enable notifications
-
-
- Turns notifications on or off.
-
-
-
-
-
- {isBrowser && (
-
- Your browser may ask for permission the first time.
-
- )}
-
- {nativeNotificationsEnabled && canShowNotifications && (
-
-
-
- Notify while app is focused
-
-
- When off, only notify when you are not looking at OpenChamber.
-
-
-
setNotificationMode(checked ? 'always' : 'hidden-only')}
- className="data-[state=checked]:bg-status-info"
- />
-
- )}
-
- {nativeNotificationsEnabled && canShowNotifications && (
-
-
-
- Events
-
-
- Choose which events trigger notifications.
-
+ {/* --- Global Delivery Settings --- */}
+
+
+
+ Notification Delivery
+
-
-
-
Completion
-
Agent finished its task.
-
-
-
-
-
-
-
Errors
-
A tool call failed.
-
-
-
-
-
-
-
Questions
-
Agent is asking for input or permission.
-
-
-
-
-
-
-
Subagents
-
Also notify for child sessions started by the main one.
-
-
setNotifyOnSubtasks(checked)}
- className="data-[state=checked]:bg-status-info"
- />
-
-
- )}
-
- {nativeNotificationsEnabled && canShowNotifications && (
-
-
-
- Customize content
-
-
- Use template variables: {'{project_name}'}{' '}
- {'{worktree}'}{' '}
- {'{branch}'}{' '}
- {'{session_name}'}{' '}
- {'{agent_name}'}{' '}
- {'{last_message}'}
-
-
-
- {(['completion', 'error', 'question', 'subtask'] as const).map((event) => (
-
- ))}
-
- )}
-
- {nativeNotificationsEnabled && canShowNotifications && (
-
-
-
- Summarization
-
-
- Summarize long messages in notifications using AI.
-
-
-
-
-
-
- Summarize last message
-
-
- Uses AI to shorten the {'{last_message}'} variable.
-
-
-
-
-
- {summarizeLastMessage ? (
- <>
-
-
-
- Summary threshold
-
- {summaryThreshold} chars
-
-
- Messages longer than this will be summarized.
-
-
setSummaryThreshold(Number(e.target.value))}
- className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- />
-
-
-
-
-
- Summary length
-
- {summaryLength} chars
-
-
- Target length of the summary.
-
-
setSummaryLength(Number(e.target.value))}
- className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- />
-
- >
- ) : (
-
-
-
- Max last message length
-
- {maxLastMessageLength} chars
-
-
- Truncate {'{last_message}'} to this many characters.
-
-
setMaxLastMessageLength(Number(e.target.value))}
- className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
+
+ {
+ void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
+ }}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
+ }
+ }}
+ >
+ {
+ void handleToggleChange(checked);
+ }}
+ ariaLabel="Enable notifications"
/>
+ Enable Notifications
+
+
+ {nativeNotificationsEnabled && canShowNotifications && (
+ setNotificationMode(notificationMode === 'always' ? 'hidden-only' : 'always')}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setNotificationMode(notificationMode === 'always' ? 'hidden-only' : 'always');
+ }
+ }}
+ >
+ setNotificationMode(checked ? 'always' : 'hidden-only')}
+ ariaLabel="Notify while app is focused"
+ />
+ Notify While App is Focused
+
+ )}
+
+
+ {isBrowser && (
+
+
+ Your browser may ask for permission the first time.
+
+ {notificationPermission === 'denied' && (
+
+ Notification permission denied. Enable it in your browser settings.
+
+ )}
+ {notificationPermission === 'granted' && !nativeNotificationsEnabled && (
+
+ Permission granted, but notifications are disabled.
+
+ )}
+
+ )}
+ {isVSCode && (
+
+
+ VS Code runtime handles notifications separately natively.
+
)}
- )}
- {isBrowser && (
- <>
- {notificationPermission === 'denied' && (
-
- Notification permission denied. Enable it in your browser settings.
-
- )}
+ {nativeNotificationsEnabled && canShowNotifications && (
+ <>
+ {/* --- Events --- */}
+
+
+
+ Notification Events
+
+
- {notificationPermission === 'granted' && !nativeNotificationsEnabled && (
-
- Permission granted, but notifications are disabled.
-
- )}
+
+ setNotifyOnCompletion(!notifyOnCompletion)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setNotifyOnCompletion(!notifyOnCompletion);
+ }
+ }}
+ >
+
+ Agent Completion
+
-
-
- Background (Push)
-
-
- Get notified even if this page is closed.
-
-
+ setNotifyOnSubtasks(!notifyOnSubtasks)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setNotifyOnSubtasks(!notifyOnSubtasks);
+ }
+ }}
+ >
+
+ Subagent Completion
+
- {!pushSupported ? (
-
- Push not supported in this browser.
-
- ) : (
-
- Desktop Chrome/Edge and Android support push. iOS requires an installed PWA.
-
- )}
+ setNotifyOnError(!notifyOnError)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setNotifyOnError(!notifyOnError);
+ }
+ }}
+ >
+
+ Agent Errors
+
- {pushSupported && (
-
-
-
- Enable push notifications
-
-
- Clicking a notification opens the relevant session.
+
setNotifyOnQuestion(!notifyOnQuestion)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setNotifyOnQuestion(!notifyOnQuestion);
+ }
+ }}
+ >
+
+ Agent Questions
+
+
+
+
+ {/* --- Template Customization --- */}
+
+
+
+ Notification Templates
+
+
+ Variables: {'{project_name}'} {'{worktree}'} {'{branch}'} {'{session_name}'} {'{agent_name}'} {'{model_name}'} {'{last_message}'}
-
- {pushBusy && (
-
-
+
+ {(['completion', 'subtask', 'error', 'question'] as const).map((event) => (
+
+
+ {event === 'subtask' ? 'Subagent Completion' : event}
+
+
+
+ ))}
+
+
+
+ {/* --- Summarization --- */}
+
+
+
+ AI Summarization
+
+
+
+
+ setSummarizeLastMessage(!summarizeLastMessage)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setSummarizeLastMessage(!summarizeLastMessage);
+ }
+ }}
+ >
+
+ Summarize Last Message
+
+
+ {summarizeLastMessage ? (
+ <>
+
+
+ Threshold
+ Messages longer than this will be summarized
+
+
+
+ setSummaryThreshold(DEFAULT_SUMMARY_THRESHOLD)}
+ disabled={summaryThreshold === DEFAULT_SUMMARY_THRESHOLD}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset threshold"
+ title="Reset"
+ >
+
+
+
+
+
+
+ Length
+ Target character length of the summary
+
+
+
+ setSummaryLength(DEFAULT_SUMMARY_LENGTH)}
+ disabled={summaryLength === DEFAULT_SUMMARY_LENGTH}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset summary length"
+ title="Reset"
+ >
+
+
+
+
+ >
+ ) : (
+
+
+ Max Length
+ Truncate {'{last_message}'} to this length
+
+
+
+ setMaxLastMessageLength(DEFAULT_MAX_LAST_MESSAGE_LENGTH)}
+ disabled={maxLastMessageLength === DEFAULT_MAX_LAST_MESSAGE_LENGTH}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset max message length"
+ title="Reset"
+ >
+
+
+
)}
+
+
+ >
+ )}
-
{
+ {/* --- Background Push Notifications --- */}
+ {isBrowser && (
+
+
+
+ Background Push Notifications
+
+
+
+
+
+
{
if (checked) {
void handleEnableBackgroundNotifications();
} else {
void handleDisableBackgroundNotifications();
}
}}
- className="data-[state=checked]:bg-status-info"
+ ariaLabel="Enable push notifications"
/>
+
+ Enable push notifications
+
+ {!pushSupported
+ ? "Push not supported. Desktop Chrome/Edge and Android support push. iOS requires an installed PWA."
+ : "Receive alerts via your operating system background service"}
+
+
+ {pushBusy && (
+
+
+
+ )}
-
- )}
- >
- )}
+
+
+ )}
- {isVSCode && (
-
-
- Delivery
-
-
- VS Code runtime handles notifications separately.
-
-
- )}
);
};
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
index a1d54552..36f39f16 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
@@ -5,7 +5,6 @@ import { SessionRetentionSettings } from './SessionRetentionSettings';
import { MemoryLimitsSettings } from './MemoryLimitsSettings';
import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings';
-import { WorktreeSectionContent } from './WorktreeSectionContent';
import { NotificationSettings } from './NotificationSettings';
import { GitHubSettings } from './GitHubSettings';
import { VoiceSettings } from './VoiceSettings';
@@ -14,7 +13,7 @@ import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
-import type { OpenChamberSection } from './OpenChamberSidebar';
+import type { OpenChamberSection } from './types';
interface OpenChamberPageProps {
/** Which section to display. If undefined, shows all sections (mobile/legacy behavior) */
@@ -34,7 +33,7 @@ export const OpenChamberPage: React.FC
= ({ section }) =>
outerClassName="h-full"
className="w-full"
>
-
+
@@ -87,7 +86,7 @@ export const OpenChamberPage: React.FC
= ({ section }) =>
outerClassName="h-full"
className="w-full"
>
-
+
{renderSectionContent()}
@@ -134,15 +133,6 @@ const GitSectionContent: React.FC = () => {
return (
-
-
-
Worktree
-
- Configure worktree branch defaults and manage existing worktrees.
-
-
-
-
);
};
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx
deleted file mode 100644
index 1249396c..00000000
--- a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import React from 'react';
-import { RiRestartLine } from '@remixicon/react';
-import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import { useDeviceInfo } from '@/lib/device';
-import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
-import { AboutSettings } from './AboutSettings';
-import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
-import { cn } from '@/lib/utils';
-
-export type OpenChamberSection = 'visual' | 'chat' | 'shortcuts' | 'sessions' | 'git' | 'github' | 'notifications' | 'voice';
-
-interface OpenChamberSidebarProps {
- selectedSection: OpenChamberSection;
- onSelectSection: (section: OpenChamberSection) => void;
-}
-
-interface SectionGroup {
- id: OpenChamberSection;
- label: string;
- items: string[];
- badge?: string;
- webOnly?: boolean;
- hideInVSCode?: boolean;
-}
-
-const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
- {
- id: 'visual',
- label: 'Visual',
- items: ['Theme', 'Font', 'Spacing'],
- },
- {
- id: 'chat',
- label: 'Chat',
- items: ['Tools', 'Diff', 'Reasoning'],
- },
- {
- id: 'shortcuts',
- label: 'Shortcuts',
- items: ['Keyboard', 'Overrides'],
- },
- {
- id: 'sessions',
- label: 'Sessions',
- items: ['Defaults', 'Zen Model', 'Retention'],
- },
- {
- id: 'git',
- label: 'Git',
- items: ['Commit Messages', 'Worktree'],
- hideInVSCode: true,
- },
- {
- id: 'github',
- label: 'GitHub',
- items: ['Connect', 'PRs', 'Issues'],
- hideInVSCode: true,
- },
- {
- id: 'notifications',
- label: 'Notifications',
- items: ['Native'],
- },
- {
- id: 'voice',
- label: 'Voice',
- items: ['Language', 'Continuous Mode'],
- badge: 'experimental',
- hideInVSCode: true,
- },
-];
-
-export const OpenChamberSidebar: React.FC
= ({
- selectedSection,
- onSelectSection,
-}) => {
- const { isMobile } = useDeviceInfo();
- const showAbout = isMobile && isWebRuntime();
- const [isReloadingConfig, setIsReloadingConfig] = React.useState(false);
-
- const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
- const isWeb = React.useMemo(() => isWebRuntime(), []);
- const showReload = !isVSCode;
-
- const handleReloadConfiguration = React.useCallback(async () => {
- setIsReloadingConfig(true);
- try {
- await reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] });
- } finally {
- setIsReloadingConfig(false);
- }
- }, []);
-
- const visibleSections = React.useMemo(() => {
- return OPENCHAMBER_SECTION_GROUPS.filter((group) => {
- if (group.webOnly && !isWeb) return false;
- if (group.hideInVSCode && isVSCode) return false;
- return true;
- });
- }, [isWeb, isVSCode]);
-
- // Desktop app: transparent for blur effect
- // VS Code: bg-background (same as page content)
- // Web/mobile: bg-sidebar
- const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
-
- return (
-
-
-
- {visibleSections.map((group) => {
- const isSelected = selectedSection === group.id;
- return (
-
-
onSelectSection(group.id)}
- className="w-full text-left flex flex-col gap-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
- >
-
-
- {group.label}
-
- {group.badge && (
-
- {group.badge}
-
- )}
-
-
- {group.items.join(' · ')}
-
-
-
- );
- })}
-
-
-
- {(showReload || showAbout) && (
-
- {showAbout ? (
- <>
- {showReload && (
-
-
- void handleReloadConfiguration()}
- disabled={isReloadingConfig}
- >
-
- {isReloadingConfig ? 'Reloading OpenCode…' : 'Reload OpenCode'}
-
-
-
- Restart OpenCode and reload its configuration (agents, commands, skills, providers).
-
-
- )}
-
- >
- ) : (
-
- {showReload && (
-
-
- void handleReloadConfiguration()}
- disabled={isReloadingConfig}
- >
-
- {isReloadingConfig ? 'Reloading OpenCode…' : 'Reload OpenCode'}
-
-
-
- Restart OpenCode and reload its configuration (agents, commands, skills, providers).
-
-
- )}
-
-
- )}
-
- )}
-
-
- );
-};
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
index c22d37c6..86194a8b 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
@@ -1,15 +1,16 @@
import React from 'react';
-import { RiRestartLine } from '@remixicon/react';
+import { RiRestartLine, RiInformationLine } from '@remixicon/react';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { cn, getModifierLabel } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small';
-import { NumberInput } from '@/components/ui/number-input';
-import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
+import { NumberInput } from '@/components/ui/number-input';
+import { Radio } from '@/components/ui/radio';
import {
Select,
SelectContent,
@@ -55,17 +56,17 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
{
id: 'dynamic',
label: 'Dynamic',
- description: 'New files inline, modified files side-by-side. Responsive inline fallback only in Dynamic mode.',
+ description: 'New inline, modified side-by-side.',
},
{
id: 'inline',
label: 'Always inline',
- description: 'Show all file diffs as a single unified view.',
+ description: 'Show as a single unified view.',
},
{
id: 'side-by-side',
label: 'Always side-by-side',
- description: 'Compare original and modified files next to each other.',
+ description: 'Compare original and modified files.',
},
];
@@ -73,12 +74,12 @@ const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [
{
id: 'single',
label: 'Single file',
- description: 'Show one file at a time in the Diff tab.',
+ description: 'Show one file at a time.',
},
{
id: 'stacked',
label: 'All files',
- description: 'Stack all changed files together in the Diff tab.',
+ description: 'Stack all changed files together.',
},
];
@@ -166,615 +167,550 @@ export const OpenChamberVisualSettings: React.FC
return visibleSettings.includes(setting);
};
+ const hasAppearanceSettings = shouldShow('theme') && !isVSCodeRuntime();
+ const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
+ const hasBehaviorSettings = shouldShow('toolOutput')
+ || shouldShow('diffLayout')
+ || shouldShow('dotfiles')
+ || shouldShow('reasoning')
+ || shouldShow('queueMode')
+ || shouldShow('textJustificationActivity')
+ || shouldShow('persistDraft')
+ || (shouldShow('terminalQuickKeys') && !isMobile);
return (
-
- {shouldShow('theme') && !isVSCodeRuntime() && (
-
-
-
- Theme Mode
-
-
+
-
- {THEME_MODE_OPTIONS.map((option) => (
- setThemeMode(option.value)}
- >
- {option.label}
-
- ))}
-
+ {/* --- Appearance & Themes --- */}
+ {hasAppearanceSettings && (
+
+
-
-
-
Light Theme
-
-
-
-
-
- {lightThemes.map((theme) => (
-
- {formatThemeLabel(theme.metadata.name, 'light')}
-
- ))}
-
-
-
+
+
+
Color Mode
+
+ {THEME_MODE_OPTIONS.map((option) => (
+ setThemeMode(option.value)}
+ >
+ {option.label}
+
+ ))}
+
+
+
-
-
Dark Theme
-
-
-
-
-
- {darkThemes.map((theme) => (
-
- {formatThemeLabel(theme.metadata.name, 'dark')}
-
- ))}
-
-
-
-
-
-
-
{
- setThemesReloading(true);
- try {
- await reloadCustomThemes();
- } finally {
- setThemesReloading(false);
- }
- }}
- className="typography-ui-label text-muted-foreground hover:text-foreground hover:underline underline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
- >
-
- Reload custom themes
-
-
- Import themes from ~/.config/openchamber/themes/
-
-
-
- )}
-
- {shouldShow('fontSize') && !isMobile && (
-
-
-
- Font Size
-
-
-
-
- setFontSize(Number(e.target.value))}
- className="flex-1 min-w-0 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- />
-
- setFontSize(100)}
- disabled={fontSize === 100}
- className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
- aria-label="Reset font size"
- title="Reset"
- >
-
-
-
-
- )}
-
- {shouldShow('terminalFontSize') && (
-
-
-
- Terminal Font Size
-
-
-
- {isMobile ? (
-
- setTerminalFontSize(Number(e.target.value))}
- className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- aria-label="Terminal font size"
- />
-
-
- {terminalFontSize}px
-
-
- setTerminalFontSize(13)}
- disabled={terminalFontSize === 13}
- className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
- aria-label="Reset terminal font size"
- title="Reset"
- >
-
-
-
- ) : (
-
- setTerminalFontSize(Number(e.target.value))}
- className="flex-1 min-w-0 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- />
-
- setTerminalFontSize(13)}
- disabled={terminalFontSize === 13}
- className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
- aria-label="Reset terminal font size"
- title="Reset"
- >
-
-
-
- )}
-
- )}
-
- {shouldShow('spacing') && (
-
-
-
- Spacing
-
-
-
-
- {isMobile ? (
-
- setPadding(Number(e.target.value))}
- className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- aria-label="Spacing percentage"
- />
-
-
- {padding}
-
-
- setPadding(100)}
- disabled={padding === 100}
- className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
- aria-label="Reset spacing"
- title="Reset"
- >
-
-
-
- ) : (
-
- setPadding(Number(e.target.value))}
- className="flex-1 min-w-0 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- />
-
- setPadding(100)}
- disabled={padding === 100}
- className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
- aria-label="Reset spacing"
- title="Reset"
- >
-
-
-
- )}
-
- )}
-
- {shouldShow('terminalQuickKeys') && !isMobile && (
-
-
-
- Show terminal optional key bar
-
-
- Esc, Ctrl, arrows, Enter.
-
-
-
-
-
- )}
-
- {shouldShow('cornerRadius') && (
-
-
-
- Input Field Corner Radius
-
-
-
- {isMobile ? (
-
- setCornerRadius(Number(e.target.value))}
- className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- aria-label="Corner radius in pixels"
- />
-
-
- {cornerRadius}px
-
-
- setCornerRadius(12)}
- disabled={cornerRadius === 12}
- className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
- aria-label="Reset corner radius"
- title="Reset"
- >
-
-
-
- ) : (
-
- setCornerRadius(Number(e.target.value))}
- className="flex-1 min-w-0 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- aria-label="Corner radius in pixels"
- />
-
- setCornerRadius(12)}
- disabled={cornerRadius === 12}
- className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
- aria-label="Reset corner radius"
- title="Reset"
- >
-
-
-
- )}
-
- )}
-
- {shouldShow('inputBarOffset') && (
-
-
-
- Input Bar Offset
-
-
- Raise the input bar to avoid screen obstructions.
-
-
-
- {isMobile ? (
-
- setInputBarOffset(Number(e.target.value))}
- className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- aria-label="Input bar offset in pixels"
- />
-
-
- {inputBarOffset}px
-
-
- setInputBarOffset(0)}
- disabled={inputBarOffset === 0}
- className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
- aria-label="Reset input bar offset"
- title="Reset"
- >
-
-
-
- ) : (
-
- setInputBarOffset(Number(e.target.value))}
- className="flex-1 min-w-0 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
- aria-label="Input bar offset in pixels"
- />
-
- setInputBarOffset(0)}
- disabled={inputBarOffset === 0}
- className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
- aria-label="Reset input bar offset"
- title="Reset"
- >
-
-
-
- )}
-
- )}
-
- {shouldShow('toolOutput') && (
-
-
-
- Default Tool Output
-
-
- {TOOL_EXPANSION_OPTIONS.find(o => o.value === toolCallExpansion)?.description}
-
-
-
- {TOOL_EXPANSION_OPTIONS.map((option) => (
- setToolCallExpansion(option.value)}
- >
- {option.label}
-
- ))}
-
-
- )}
-
- {shouldShow('diffLayout') && !isMobile && !isVSCodeRuntime() && (
-
-
-
- Diff layout (Diff tab)
-
-
- Choose the default layout for file diffs. You can still override layout per file from the Diff tab.
-
-
-
-
-
- {DIFF_LAYOUT_OPTIONS.map((option) => (
+
+
+ Light Theme
+
+
+
+
+
+ {lightThemes.map((theme) => (
+
+ {formatThemeLabel(theme.metadata.name, 'light')}
+
+ ))}
+
+
+
+
+ Dark Theme
+
+
+
+
+
+ {darkThemes.map((theme) => (
+
+ {formatThemeLabel(theme.metadata.name, 'dark')}
+
+ ))}
+
+
+
+
+
setDiffLayoutPreference(option.id)}
+ type="button"
+ variant="outline"
+ size="xs"
+ disabled={customThemesLoading || themesReloading}
+ onClick={async () => {
+ setThemesReloading(true);
+ try {
+ await reloadCustomThemes();
+ } finally {
+ setThemesReloading(false);
+ }
+ }}
+ className="!font-normal"
>
- {option.label}
+
+ Reload themes
- ))}
-
-
- {DIFF_LAYOUT_OPTIONS.find((option) => option.id === diffLayoutPreference)?.description}
-
+
+
+
+
+
+
+
+ Import custom themes from ~/.config/openchamber/themes/
+
+
+
+
+ )}
-
-
- Diff view (Diff tab)
-
-
- Choose whether the Diff tab defaults to a single file or all files.
-
+ {/* --- UI Scaling & Layout --- */}
+ {hasLayoutSettings && (
+
+
+
+ {shouldShow('fontSize') && !isMobile && (
+
+
+ Interface Font Size
+
+
+
+ setFontSize(100)}
+ disabled={fontSize === 100}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset font size"
+ title="Reset"
+ >
+
+
+
+
+ )}
+
+ {shouldShow('terminalFontSize') && (
+
+
+ Terminal Font Size
+
+
+
+ setTerminalFontSize(13)}
+ disabled={terminalFontSize === 13}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset terminal font size"
+ title="Reset"
+ >
+
+
+
+
+ )}
+
+ {shouldShow('spacing') && (
+
+
+ Spacing Density
+
+
+
+ setPadding(100)}
+ disabled={padding === 100}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset spacing"
+ title="Reset"
+ >
+
+
+
+
+ )}
+
+ {shouldShow('cornerRadius') && (
+
+
+ Corner Radius
+
+
+
+ setCornerRadius(12)}
+ disabled={cornerRadius === 12}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset corner radius"
+ title="Reset"
+ >
+
+
+
+
+ )}
+
+ {shouldShow('inputBarOffset') && (
+
+
+
+ Input Bar Offset
+
+
+
+
+
+ Raise input bar to avoid OS-level screen obstructions like home bars.
+
+
+
+
+
+
+ setInputBarOffset(0)}
+ disabled={inputBarOffset === 0}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset input bar offset"
+ title="Reset"
+ >
+
+
+
+
+ )}
+
+
+ )}
-
-
- {DIFF_VIEW_MODE_OPTIONS.map((option) => (
- setDiffViewMode(option.id)}
- >
- {option.label}
-
- ))}
-
-
- {DIFF_VIEW_MODE_OPTIONS.find((option) => option.id === diffViewMode)?.description}
-
+ {hasBehaviorSettings && (
+
+
+ {shouldShow('toolOutput') && (
+
+ Default Tool Output
+
+ {TOOL_EXPANSION_OPTIONS.map((option) => {
+ return (
+ setToolCallExpansion(option.value)}
+ >
+ {option.label}
+
+ );
+ })}
+
+
+ )}
+
+ {shouldShow('diffLayout') && !isVSCodeRuntime() && (
+
+ Diff Layout
+
+ {DIFF_LAYOUT_OPTIONS.map((option) => {
+ const selected = diffLayoutPreference === option.id;
+ return (
+
setDiffLayoutPreference(option.id)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setDiffLayoutPreference(option.id);
+ }
+ }}
+ className="flex w-full items-center gap-2 py-0.5 text-left"
+ >
+ setDiffLayoutPreference(option.id)}
+ ariaLabel={`Diff layout: ${option.label}`}
+ />
+
+ {option.label}
+
+
+ );
+ })}
+
+
+ )}
+
+ {shouldShow('diffLayout') && !isVSCodeRuntime() && (
+
+ Diff View Mode
+
+ {DIFF_VIEW_MODE_OPTIONS.map((option) => {
+ const selected = diffViewMode === option.id;
+ return (
+
setDiffViewMode(option.id)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setDiffViewMode(option.id);
+ }
+ }}
+ className="flex w-full items-center gap-2 py-0.5 text-left"
+ >
+ setDiffViewMode(option.id)}
+ ariaLabel={`Diff view mode: ${option.label}`}
+ />
+
+ {option.label}
+
+
+ );
+ })}
+
+
+ )}
+
+ {(shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && (
+
+ {shouldShow('dotfiles') && !isVSCodeRuntime() && (
+ setDirectoryShowHidden(!directoryShowHidden)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setDirectoryShowHidden(!directoryShowHidden);
+ }
+ }}
+ >
+
+ Show Dotfiles
+
+ )}
+
+ {shouldShow('queueMode') && (
+ setQueueMode(!queueModeEnabled)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setQueueMode(!queueModeEnabled);
+ }
+ }}
+ >
+
+
+ Queue Messages by Default
+
+
+
+
+
+ When enabled, Enter queues messages. Use {getModifierLabel()}+Enter to send.
+
+
+
+
+ )}
+
+ {shouldShow('persistDraft') && (
+ setPersistChatDraft(!persistChatDraft)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setPersistChatDraft(!persistChatDraft);
+ }
+ }}
+ >
+
+ Persist Draft Messages
+
+ )}
+
+ {shouldShow('reasoning') && (
+ setShowReasoningTraces(!showReasoningTraces)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setShowReasoningTraces(!showReasoningTraces);
+ }
+ }}
+ >
+
+ Show Reasoning Traces
+
+ )}
+
+ {shouldShow('textJustificationActivity') && (
+ setShowTextJustificationActivity(!showTextJustificationActivity)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setShowTextJustificationActivity(!showTextJustificationActivity);
+ }
+ }}
+ >
+
+ Show Justification Activity
+
+ )}
+
+ )}
+
+ {shouldShow('terminalQuickKeys') && !isMobile && (
+
+ setShowTerminalQuickKeysOnDesktop(!showTerminalQuickKeysOnDesktop)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setShowTerminalQuickKeysOnDesktop(!showTerminalQuickKeysOnDesktop);
+ }
+ }}
+ >
+
+
+ Terminal Quick Keys
+
+
+
+
+
+ Show Esc, Ctrl, Arrows in terminal view
+
+
+
+
+
+ )}
+ )}
-
- )}
-
- {shouldShow('dotfiles') && !isVSCodeRuntime() && (
-
-
-
- Hidden files (Chat)
-
-
- Show or hide dotfiles in file lists and directory pickers.
-
-
-
-
-
- {[
- { id: 'hide', label: 'Hide', value: false },
- { id: 'show', label: 'Show', value: true },
- ].map((option) => (
- setDirectoryShowHidden(option.value)}
- >
- {option.label}
-
- ))}
-
-
-
- )}
-
- {shouldShow('reasoning') && (
-
-
-
- Show thinking / reasoning traces
-
-
- )}
-
- {shouldShow('textJustificationActivity') && (
-
-
-
- Show text justification in activity
-
-
- )}
-
- {shouldShow('queueMode') && (
-
-
-
-
- Queue messages by default
-
-
-
- {queueModeEnabled
- ? `Enter queues messages, ${getModifierLabel()}+Enter sends immediately.`
- : `Enter sends immediately, ${getModifierLabel()}+Enter queues messages.`}
-
-
- )}
-
- {shouldShow('persistDraft') && (
-
-
-
-
- Persist chat input draft
-
-
-
- Save your typed message across page reloads and session switches.
-
-
- )}
-
+
);
};
diff --git a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx
index 504dc6ee..35315b19 100644
--- a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx
@@ -1,6 +1,8 @@
import * as React from 'react';
-import { Button } from '@/components/ui/button';
+import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { RiFolderLine, RiInformationLine } from '@remixicon/react';
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
@@ -79,45 +81,69 @@ export const OpenCodeCliSettings: React.FC = () => {
}, [value]);
return (
-
-
-
OpenCode CLI
-
- Optional absolute path to the opencode binary.
- Useful when your desktop app launch environment has a stale PATH.
- If your opencode shim requires Node/Bun (e.g. env node or env bun), make sure that runtime is installed.
-
+
+
+
+
+ OpenCode CLI
+
+
+
+
+
+
+ Optional absolute path to the opencode binary.
+
+
+
-
- setValue(e.target.value)}
- placeholder="/Users/you/.bun/bin/opencode"
- disabled={isLoading || isSaving}
- className="flex-1 font-mono text-xs"
- />
-
- Browse
-
-
- {isSaving ? 'Saving…' : 'Save + Reload'}
-
-
+
+
+
+ OpenCode Binary Path
+
+
+ setValue(e.target.value)}
+ placeholder="/Users/you/.bun/bin/opencode"
+ disabled={isLoading || isSaving}
+ className="h-7 min-w-0 flex-1 font-mono text-xs"
+ />
+
+
+
+
+
-
- Tip: you can also use OPENCODE_BINARY env var, but this setting persists in
- ~/.config/openchamber/settings.json .
-
+
+
+ Tip: you can also use OPENCODE_BINARY env var, but this setting persists in ~/.config/openchamber/settings.json .
+
+
+
+
+
+ {isSaving ? 'Saving…' : 'Save + Reload'}
+
+
+
);
};
diff --git a/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx b/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx
index 14fc69e1..a42b23ab 100644
--- a/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx
@@ -1,30 +1,23 @@
import React from 'react';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { RiInformationLine, RiRestartLine } from '@remixicon/react';
import { toast } from '@/components/ui';
-import { RiInformationLine } from '@remixicon/react';
import { NumberInput } from '@/components/ui/number-input';
import { ButtonSmall } from '@/components/ui/button-small';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Checkbox } from '@/components/ui/checkbox';
-import { useDeviceInfo } from '@/lib/device';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
const MIN_DAYS = 1;
const MAX_DAYS = 365;
+const DEFAULT_RETENTION_DAYS = 30;
export const SessionRetentionSettings: React.FC = () => {
- const { isMobile } = useDeviceInfo();
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
const setAutoDeleteEnabled = useUIStore((state) => state.setAutoDeleteEnabled);
const setAutoDeleteAfterDays = useUIStore((state) => state.setAutoDeleteAfterDays);
- const [mobileDraftDays, setMobileDraftDays] = React.useState(String(autoDeleteAfterDays));
-
- React.useEffect(() => {
- setMobileDraftDays(String(autoDeleteAfterDays));
- }, [autoDeleteAfterDays]);
-
const { candidates, isRunning, runCleanup } = useSessionAutoCleanup({ autoRun: false });
const pendingCount = candidates.length;
@@ -43,69 +36,50 @@ export const SessionRetentionSettings: React.FC = () => {
}, [runCleanup]);
return (
-
-
+
+
-
Session retention
+
+ Session Retention
+
- Automatically delete inactive sessions based on their last activity.
- You can also run a one-time cleanup without enabling auto-cleanup.
- Keeps the most recent 5 sessions, and never deletes shared sessions.
+ Automatically delete inactive sessions based on their last activity. Keeps recent 5 sessions.
-
-
- Enable auto-cleanup
-
+
+ setAutoDeleteEnabled(!autoDeleteEnabled)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setAutoDeleteEnabled(!autoDeleteEnabled);
+ }
+ }}
+ >
+
+ Enable Auto-Cleanup
+
-
-
- {isMobile ? (
-
{
- const nextValue = event.target.value;
- setMobileDraftDays(nextValue);
- if (nextValue.trim() === '') {
- return;
- }
- const parsed = Number(nextValue);
- if (!Number.isFinite(parsed)) {
- return;
- }
- const clamped = Math.min(MAX_DAYS, Math.max(MIN_DAYS, Math.round(parsed)));
- setAutoDeleteAfterDays(clamped);
- }}
- onBlur={() => {
- if (mobileDraftDays.trim() === '') {
- setMobileDraftDays(String(autoDeleteAfterDays));
- return;
- }
- const parsed = Number(mobileDraftDays);
- if (!Number.isFinite(parsed)) {
- setMobileDraftDays(String(autoDeleteAfterDays));
- return;
- }
- const clamped = Math.min(MAX_DAYS, Math.max(MIN_DAYS, Math.round(parsed)));
- setAutoDeleteAfterDays(clamped);
- setMobileDraftDays(String(clamped));
- }}
- aria-label="Retention period in days"
- className="h-8 w-16 rounded-lg border border-border bg-background px-2 text-center typography-ui-label text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/50"
- />
- ) : (
+
+
+ Retention Period
+
+
{
max={MAX_DAYS}
step={1}
aria-label="Retention period in days"
+ className="w-20 tabular-nums"
/>
- )}
- days since last activity
+ days
+ setAutoDeleteAfterDays(DEFAULT_RETENTION_DAYS)}
+ disabled={autoDeleteAfterDays === DEFAULT_RETENTION_DAYS}
+ className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
+ aria-label="Reset retention period"
+ title="Reset"
+ >
+
+
+
-
- {isRunning ? 'Cleaning up...' : 'Run cleanup now'}
-
-
+
-
- Eligible for deletion right now: {pendingCount}
+
+
+
+
+
+ {isRunning ? 'Cleaning up...' : 'Run cleanup now'}
+
+
+
+
+ Eligible for deletion right now: {pendingCount}
+
);
diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
index b9457951..41924746 100644
--- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useBrowserVoice } from '@/hooks/useBrowserVoice';
import { useConfigStore } from '@/stores/useConfigStore';
-import { SettingsSection } from '@/components/sections/shared/SettingsSection';
+import { useDeviceInfo } from '@/lib/device';
import {
Select,
@@ -10,14 +10,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
-import { Switch } from '@/components/ui/switch';
-import { Button } from '@/components/ui/button';
-import { Slider } from '@/components/ui/slider';
-import { RiMicLine, RiAlertLine, RiVolumeUpLine, RiSpeedLine, RiMusicLine, RiSoundModuleLine, RiAppleLine, RiPlayLine, RiStopLine, RiChromeLine, RiFileTextLine, RiKeyLine, RiCloseLine } from '@remixicon/react';
+import { Checkbox } from '@/components/ui/checkbox';
+import { ButtonSmall } from '@/components/ui/button-small';
+import { NumberInput } from '@/components/ui/number-input';
+import { RiPlayLine, RiStopLine, RiCloseLine, RiAppleLine, RiInformationLine } from '@remixicon/react';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
+import { cn } from '@/lib/utils';
-// Common language options with display names
-// Shared with BrowserVoiceButton.tsx
const LANGUAGE_OPTIONS = [
{ value: 'en-US', label: 'English' },
{ value: 'es-ES', label: 'Español' },
@@ -31,11 +31,24 @@ const LANGUAGE_OPTIONS = [
{ value: 'uk-UA', label: 'Українська' },
];
-/**
- * Voice settings section for OpenChamber settings
- * Allows users to configure voice conversation preferences
- */
+const OPENAI_VOICE_OPTIONS = [
+ { value: 'alloy', label: 'Alloy' },
+ { value: 'ash', label: 'Ash' },
+ { value: 'ballad', label: 'Ballad' },
+ { value: 'coral', label: 'Coral' },
+ { value: 'echo', label: 'Echo' },
+ { value: 'fable', label: 'Fable' },
+ { value: 'nova', label: 'Nova' },
+ { value: 'onyx', label: 'Onyx' },
+ { value: 'sage', label: 'Sage' },
+ { value: 'shimmer', label: 'Shimmer' },
+ { value: 'verse', label: 'Verse' },
+ { value: 'marin', label: 'Marin' },
+ { value: 'cedar', label: 'Cedar' },
+];
+
export const VoiceSettings: React.FC = () => {
+ const { isMobile } = useDeviceInfo();
const {
isSupported,
language,
@@ -72,22 +85,18 @@ export const VoiceSettings: React.FC = () => {
setSummarizeMaxLength,
} = useConfigStore();
- // Check if macOS 'say' is available and get voices
const [isSayAvailable, setIsSayAvailable] = useState(false);
const [sayVoices, setSayVoices] = useState
>([]);
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false);
const [previewAudio, setPreviewAudio] = useState(null);
- // Check if OpenAI TTS is available
const [isOpenAIAvailable, setIsOpenAIAvailable] = useState(false);
const [isOpenAIPreviewPlaying, setIsOpenAIPreviewPlaying] = useState(false);
const [openaiPreviewAudio, setOpenaiPreviewAudio] = useState(null);
- // Browser voices
const [browserVoices, setBrowserVoices] = useState([]);
const [isBrowserPreviewPlaying, setIsBrowserPreviewPlaying] = useState(false);
- // Load browser voices
useEffect(() => {
const loadVoices = async () => {
const voices = await browserVoiceService.waitForVoices();
@@ -95,7 +104,6 @@ export const VoiceSettings: React.FC = () => {
};
loadVoices();
- // Also listen for voice changes (Chrome loads voices asynchronously)
if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = () => {
setBrowserVoices(window.speechSynthesis.getVoices());
@@ -109,25 +117,20 @@ export const VoiceSettings: React.FC = () => {
};
}, []);
- // Filter and sort browser voices by language
const filteredBrowserVoices = useMemo(() => {
- // Group voices by language, prioritize English voices at top
return browserVoices
- .filter(v => v.lang) // Only voices with a language
+ .filter(v => v.lang)
.sort((a, b) => {
- // Prioritize English voices
const aIsEnglish = a.lang.startsWith('en');
const bIsEnglish = b.lang.startsWith('en');
if (aIsEnglish && !bIsEnglish) return -1;
if (!aIsEnglish && bIsEnglish) return 1;
- // Then sort by language, then by name
const langCompare = a.lang.localeCompare(b.lang);
if (langCompare !== 0) return langCompare;
return a.name.localeCompare(b.name);
});
}, [browserVoices]);
- // Preview browser voice
const previewBrowserVoice = useCallback(() => {
if (isBrowserPreviewPlaying) {
browserVoiceService.cancelSpeech();
@@ -158,7 +161,6 @@ export const VoiceSettings: React.FC = () => {
window.speechSynthesis.speak(utterance);
}, [browserVoice, browserVoices, speechRate, speechPitch, speechVolume, isBrowserPreviewPlaying]);
- // Cleanup browser preview on unmount
useEffect(() => {
return () => {
if (isBrowserPreviewPlaying) {
@@ -167,39 +169,15 @@ export const VoiceSettings: React.FC = () => {
};
}, [isBrowserPreviewPlaying]);
- // OpenAI voice options
- const OPENAI_VOICE_OPTIONS = [
- { value: 'alloy', label: 'Alloy' },
- { value: 'ash', label: 'Ash' },
- { value: 'ballad', label: 'Ballad' },
- { value: 'coral', label: 'Coral' },
- { value: 'echo', label: 'Echo' },
- { value: 'fable', label: 'Fable' },
- { value: 'nova', label: 'Nova' },
- { value: 'onyx', label: 'Onyx' },
- { value: 'sage', label: 'Sage' },
- { value: 'shimmer', label: 'Shimmer' },
- { value: 'verse', label: 'Verse' },
- { value: 'marin', label: 'Marin' },
- { value: 'cedar', label: 'Cedar' },
- ];
-
- // Check OpenAI TTS availability (including API key from settings)
useEffect(() => {
const checkOpenAIAvailability = async () => {
try {
- // First check if server has API key configured
const response = await fetch('/api/tts/status');
const data = await response.json();
- console.log('[VoiceSettings] OpenAI TTS status:', data);
-
- // Available if server has key OR user has set API key in settings
const hasServerKey = data.available;
const hasSettingsKey = openaiApiKey.trim().length > 0;
setIsOpenAIAvailable(hasServerKey || hasSettingsKey);
- } catch (err) {
- console.error('[VoiceSettings] Failed to check OpenAI TTS status:', err);
- // Still available if user has set API key in settings
+ } catch {
setIsOpenAIAvailable(openaiApiKey.trim().length > 0);
}
};
@@ -211,10 +189,8 @@ export const VoiceSettings: React.FC = () => {
fetch('/api/tts/say/status')
.then(res => res.json())
.then(data => {
- console.log('[VoiceSettings] Say TTS status:', data);
setIsSayAvailable(data.available);
if (data.voices) {
- // Filter to unique voice names and sort alphabetically
const uniqueVoices = data.voices
.filter((v: { name: string; locale: string }, i: number, arr: Array<{ name: string; locale: string }>) =>
arr.findIndex((x: { name: string }) => x.name === v.name) === i
@@ -223,15 +199,12 @@ export const VoiceSettings: React.FC = () => {
setSayVoices(uniqueVoices);
}
})
- .catch((err) => {
- console.error('[VoiceSettings] Failed to check Say TTS status:', err);
+ .catch(() => {
setIsSayAvailable(false);
});
}, []);
- // Preview voice function
const previewVoice = useCallback(async () => {
- // Stop any existing preview
if (previewAudio) {
previewAudio.pause();
previewAudio.currentTime = 0;
@@ -272,13 +245,11 @@ export const VoiceSettings: React.FC = () => {
setPreviewAudio(audio);
await audio.play();
- } catch (err) {
- console.error('Voice preview failed:', err);
+ } catch {
setIsPreviewPlaying(false);
}
}, [sayVoice, speechRate, previewAudio]);
- // Cleanup preview audio on unmount
useEffect(() => {
return () => {
if (previewAudio) {
@@ -287,9 +258,7 @@ export const VoiceSettings: React.FC = () => {
};
}, [previewAudio]);
- // Preview OpenAI voice
const previewOpenAIVoice = useCallback(async () => {
- // Stop any existing preview
if (openaiPreviewAudio) {
openaiPreviewAudio.pause();
openaiPreviewAudio.currentTime = 0;
@@ -334,13 +303,11 @@ export const VoiceSettings: React.FC = () => {
setOpenaiPreviewAudio(audio);
await audio.play();
- } catch (err) {
- console.error('[VoiceSettings] OpenAI voice preview failed:', err);
+ } catch {
setIsOpenAIPreviewPlaying(false);
}
}, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey]);
- // Cleanup OpenAI preview audio on unmount
useEffect(() => {
return () => {
if (openaiPreviewAudio) {
@@ -349,526 +316,321 @@ export const VoiceSettings: React.FC = () => {
};
}, [openaiPreviewAudio]);
+ const sliderClass = "flex-1 min-w-0 h-1.5 bg-[var(--interactive-border)] rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:border-0 disabled:opacity-50";
+
return (
-
-
- {/* Voice Mode Enable/Disable */}
-
-
-
-
-
- Voice Mode
-
-
-
- Enable voice conversations with microphone input
-
-
-
+
+
+ {/* Voice Setup */}
+
+
+
+ Voice Setup
+
- {/* Voice provider selection - only show when voice mode is enabled */}
- {voiceModeEnabled && (
-
-
-
-
-
- Voice Provider
-
-
-
- Choose your preferred text-to-speech provider
-
-
-
- setVoiceProvider('browser')}
- className="min-w-[80px]"
- >
- Browser
-
- setVoiceProvider('openai')}
- className="min-w-[80px]"
- title={isOpenAIAvailable ? 'OpenAI voice' : 'OpenAI voice unavailable - API key not configured'}
- >
- OpenAI
-
- {isSayAvailable && (
- setVoiceProvider('say')}
- className="min-w-[80px]"
- >
-
- Say
-
- )}
-
-
- )}
+
- {/* Provider description */}
- {voiceModeEnabled && (
-
-
-
- {voiceProvider === 'browser' ? 'Browser Voice:' : voiceProvider === 'openai' ? 'OpenAI:' : 'macOS Say:'}
- {' '}
- {voiceProvider === 'browser'
- ? 'Free, works offline, but has limited mobile support. Best for desktop use.'
- : voiceProvider === 'openai'
- ? 'Higher quality voice synthesis that works reliably on mobile. Requires OpenAI API key.'
- : 'Native macOS speech synthesis. Free, fast, and works offline. Desktop only.'}
-
-
- )}
-
- {/* OpenAI unavailable warning */}
- {voiceModeEnabled && voiceProvider === 'openai' && !isOpenAIAvailable && (
-
-
-
-
- OpenAI voice unavailable
-
-
- OpenAI voice requires an OpenAI API key to be configured. Please set the OpenAI API key or switch to Browser voice.
-
-
-
- )}
-
- {/* OpenAI API Key Input - show when OpenAI is selected or when no server key is configured */}
- {voiceModeEnabled && voiceProvider === 'openai' && (
-
-
-
-
-
- OpenAI API Key
-
-
-
- {isOpenAIAvailable && !openaiApiKey ? 'Using API key from OpenCode configuration' : 'Enter your OpenAI API key for voice synthesis'}
-
-
-
- setOpenaiApiKey(e.target.value)}
- placeholder="sk-..."
- className="flex-1 px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
- />
- {openaiApiKey && (
- setOpenaiApiKey('')}
- title="Clear API key"
- >
-
-
- )}
-
-
- )}
-
- {/* OpenAI Voice Selection */}
- {voiceModeEnabled && voiceProvider === 'openai' && isOpenAIAvailable && (
-
-
-
-
-
- OpenAI Voice
-
-
-
- Select an OpenAI voice for text-to-speech
-
-
-
-
-
-
-
-
- {OPENAI_VOICE_OPTIONS.map((voice) => (
-
- {voice.label}
-
- ))}
-
-
-
- {isOpenAIPreviewPlaying ? (
-
- ) : (
-
- )}
-
-
-
- )}
-
- {/* macOS Say Voice Selection */}
- {voiceModeEnabled && voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
-
-
-
-
-
- macOS Voice
-
-
-
- Select a voice installed on your Mac
-
-
-
-
-
-
-
-
- {sayVoices.map((voice) => (
-
- {voice.name}
-
- ))}
-
-
-
- {isPreviewPlaying ? (
-
- ) : (
-
- )}
-
-
-
- )}
-
- {/* Browser Voice Selection */}
- {voiceModeEnabled && voiceProvider === 'browser' && filteredBrowserVoices.length > 0 && (
-
-
-
-
-
- Browser Voice
-
-
-
- Select a voice from your browser
-
-
-
- setBrowserVoice(value === '__auto__' ? '' : value)}
- >
-
-
-
-
- Auto (default)
- {filteredBrowserVoices.map((voice) => (
-
- {voice.name} ({voice.lang})
-
- ))}
-
-
-
- {isBrowserPreviewPlaying ? (
-
- ) : (
-
- )}
-
-
-
- )}
-
- {/* Language selection */}
- {voiceModeEnabled && (
-
-
-
-
-
- Language
-
-
-
- Language for speech recognition and synthesis
-
-
-
-
-
-
-
- {LANGUAGE_OPTIONS.map((lang) => (
-
- {lang.label}
-
- ))}
-
-
-
- )}
-
- {/* Show TTS buttons on messages */}
-
-
-
-
-
- Message Read Aloud
-
-
-
- Show speaker button on AI responses to read them aloud
-
-
-
-
-
- {/* Summarization Section */}
-
-
-
-
- Summarization
-
+
setVoiceModeEnabled(!voiceModeEnabled)}
+ onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setVoiceModeEnabled(!voiceModeEnabled); } }}
+ >
+
+ Enable Voice Mode
- {/* Summarize Message TTS */}
-
-
-
- Summarize Message Playback
-
-
- Summarize long messages before reading them aloud
-
-
-
-
-
- {/* Summarize Voice Conversation */}
{voiceModeEnabled && (
-
-
-
- Summarize Voice Responses
-
-
- Summarize long AI responses during voice conversations
-
-
-
-
- )}
-
- {/* Character Threshold - only show if either summarization is enabled */}
- {(summarizeMessageTTS || summarizeVoiceConversation) && (
<>
-
-
-
- Character Threshold
-
-
- Summarize text longer than this ({summarizeCharacterThreshold} chars)
-
+
+
+
+
Provider
+
+
+
+
+
+
+ Browser: Free, offline, limited mobile support.
+ OpenAI: High quality, mobile ready, needs API key.
+ Say: macOS native. Fast, free, offline.
+
+
+
+
+
+ setVoiceProvider('browser')}
+ className={cn(
+ '!font-normal',
+ voiceProvider === 'browser'
+ ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
+ : 'text-foreground'
+ )}
+ >
+ Browser
+
+ setVoiceProvider('openai')}
+ className={cn(
+ '!font-normal',
+ voiceProvider === 'openai'
+ ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
+ : 'text-foreground'
+ )}
+ >
+ OpenAI
+
+ {isSayAvailable && (
+ setVoiceProvider('say')}
+ className={cn(
+ '!font-normal',
+ voiceProvider === 'say'
+ ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
+ : 'text-foreground'
+ )}
+ >
+
+ Say
+
+ )}
+
+
-
- `${v}`}
- />
-
-
-
-
-
- Summary Length Limit
-
-
- Max characters for the summary ({summarizeMaxLength} chars)
-
+ {/* OpenAI API Key */}
+ {voiceProvider === 'openai' && (
+
+
+ API Key
+
+
+ {isOpenAIAvailable && !openaiApiKey ? 'Using key from configuration' : !isOpenAIAvailable ? 'OpenAI TTS requires an API key' : 'Provide your OpenAI key'}
+
+
+ setOpenaiApiKey(e.target.value)}
+ placeholder="sk-..."
+ className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
+ />
+ {openaiApiKey && (
+ setOpenaiApiKey('')}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ >
+
+
+ )}
+
+
+ )}
+
+ {/* Voice Selection */}
+
+
Voice
+
+ {voiceProvider === 'openai' && isOpenAIAvailable && (
+ <>
+
+
+
+
+
+ {OPENAI_VOICE_OPTIONS.map((v) => (
+ {v.label}
+ ))}
+
+
+
+ {isOpenAIPreviewPlaying ? : }
+
+ >
+ )}
+
+ {voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
+ <>
+
+
+
+
+
+ {sayVoices.map((v) => (
+ {v.name}
+ ))}
+
+
+
+ {isPreviewPlaying ? : }
+
+ >
+ )}
+
+ {voiceProvider === 'browser' && filteredBrowserVoices.length > 0 && (
+ <>
+ setBrowserVoice(value === '__auto__' ? '' : value)}>
+
+
+
+
+ Auto
+ {filteredBrowserVoices.map((v) => (
+ {v.name} ({v.lang})
+ ))}
+
+
+
+ {isBrowserPreviewPlaying ? : }
+
+ >
+ )}
+
-
-
`${v}`}
- />
+
+ {/* Speech Rate */}
+
+
Speech Rate
+
+ {!isMobile && setSpeechRate(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
+
+
+
+
+ {/* Speech Pitch */}
+
+
Speech Pitch
+
+ {!isMobile && setSpeechPitch(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
+
+
+
+
+ {/* Speech Volume */}
+
+
Speech Volume
+
+ {!isMobile && setSpeechVolume(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
+ {isMobile ? (
+ setSpeechVolume(v / 100)} min={0} max={100} step={10} className="w-16 tabular-nums" />
+ ) : (
+
+ {Math.round(speechVolume * 100)}%
+
+ )}
+
+
+
+ {/* Language */}
+
+
Language
+
+
+
+
+
+
+ {LANGUAGE_OPTIONS.map((lang) => (
+ {lang.label}
+ ))}
+
+
+
-
>
)}
+
+
+
+ {/* Playback & Summarization */}
+
+
+
+ Playback & Summarization
+
- {/* Speech Rate */}
-
-
-
-
-
- Speech Rate
-
+
+ setShowMessageTTSButtons(!showMessageTTSButtons)}
+ onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }}
+ >
+
+ Message Read Aloud Button
+
+
+ setSummarizeMessageTTS(!summarizeMessageTTS)}
+ onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeMessageTTS(!summarizeMessageTTS); } }}
+ >
+
+ Summarize Before Playback
+
+
+ {voiceModeEnabled && (
+ setSummarizeVoiceConversation(!summarizeVoiceConversation)}
+ onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeVoiceConversation(!summarizeVoiceConversation); } }}
+ >
+
+ Summarize Voice Mode Responses
-
- Speed of speech (0.5x - 2x)
-
-
-
- `${v.toFixed(1)}x`}
- />
-
-
+ )}
- {/* Speech Pitch */}
-
-
-
-
-
- Speech Pitch
-
-
-
- Voice pitch (0.5 - 2)
-
-
-
-
-
-
+ {(summarizeMessageTTS || summarizeVoiceConversation) && (
+ <>
+
+
Summarization Threshold
+
+ {!isMobile && setSummarizeCharacterThreshold(Number(e.target.value))} className={sliderClass} />}
+
+
+
- {/* Speech Volume */}
-
-
-
-
-
- Speech Volume
-
-
-
- Voice volume (0 - 100%)
-
-
-
- `${Math.round(v * 100)}%`}
- />
-
-
+
+
Summary Max Length
+
+ {!isMobile && setSummarizeMaxLength(Number(e.target.value))} className={sliderClass} />}
+
+
+
+ >
+ )}
+
- {/* Keyboard shortcut hint */}
{voiceModeEnabled && isSupported && (
-
-
- Tip: {' '}
- Press Shift +{' '}
- Click {' '}
- on the voice button to quickly toggle continuous mode
+
+
+ Press Shift + Click on the mic button to toggle continuous mode
)}
-
+
+
);
};
diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
index 9db03c99..a502e2a5 100644
--- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
+++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
@@ -1,21 +1,28 @@
import React from 'react';
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
+import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
+import { useDeviceInfo } from '@/lib/device';
import { checkIsGitRepository } from '@/lib/gitApi';
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { sessionEvents } from '@/lib/sessionEvents';
import type { WorktreeMetadata } from '@/types/worktree';
-import { formatPathForDisplay } from '@/lib/utils';
+import { formatPathForDisplay, cn } from '@/lib/utils';
-export const WorktreeSectionContent: React.FC = () => {
+export interface WorktreeSectionContentProps {
+ projectRef?: { id: string; path: string } | null;
+}
+
+export const WorktreeSectionContent: React.FC
= ({ projectRef: projectRefProp = null }) => {
+ const { isMobile } = useDeviceInfo();
const activeProject = useProjectsStore((state) => state.getActiveProject());
- const projectPath = activeProject?.path ?? null;
+ const projectPath = projectRefProp?.path ?? activeProject?.path ?? null;
const { sessions, getWorktreeMetadata } = useSessionStore();
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
@@ -27,11 +34,14 @@ export const WorktreeSectionContent: React.FC = () => {
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
const projectRef = React.useMemo(() => {
+ if (projectRefProp?.id && projectRefProp?.path) {
+ return { id: projectRefProp.id, path: projectRefProp.path };
+ }
if (!activeProject?.id || !projectPath) {
return null;
}
return { id: activeProject.id, path: projectPath };
- }, [activeProject?.id, projectPath]);
+ }, [activeProject?.id, projectPath, projectRefProp?.id, projectRefProp?.path]);
const refreshWorktrees = React.useCallback(async () => {
if (!projectRef || isGitRepoLocal === false) return;
@@ -253,60 +263,68 @@ export const WorktreeSectionContent: React.FC = () => {
}
return (
-
+
{/* Setup commands */}
-
-
-
Setup commands
-
- Run automatically inside the new worktree directory when a worktree is created.
-
- Use $ROOT_PROJECT_PATH for the project root.
-
+
+
+
+
Setup commands
+
+
+
+
+
+ Run automatically inside the new worktree directory when a worktree is created.
+ Use $ROOT_PROJECT_PATH for the project root.
+
+
+
{isLoadingCommands ? (
-
Loading...
+
Loading...
) : (
-
+
{setupCommands.map((command, index) => (
-
+
handleSetupCommandChange(index, e.target.value)}
onBlur={handleCommandBlur}
placeholder="e.g., bun install"
- className="flex-1 font-mono text-xs"
+ className="h-7 w-[30rem] max-w-full font-mono text-xs"
/>
{
handleRemoveCommand(index);
}}
- className="flex-shrink-0 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
+ className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Remove command"
>
))}
-
Add command
-
+
)}
{/* Existing worktrees */}
-
-
+
+
-
Existing worktrees
+ Existing worktrees
@@ -316,23 +334,20 @@ export const WorktreeSectionContent: React.FC = () => {
-
- Manage worktrees for this project
-
{isLoadingWorktrees ? (
-
Loading worktrees...
+
Loading worktrees...
) : availableWorktrees.length === 0 ? (
-
+
No worktrees found for this project
) : (
-
+
{availableWorktrees.map((worktree) => (
@@ -347,11 +362,14 @@ export const WorktreeSectionContent: React.FC = () => {
{formatPathForDisplay(worktree.path, homeDirectory)}
-
handleDeleteWorktree(worktree)}
- className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
- aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
+ handleDeleteWorktree(worktree)}
+ className={cn(
+ "flex-shrink-0 flex h-7 w-7 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
+ isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
+ )}
+ aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
>
diff --git a/packages/ui/src/components/sections/openchamber/types.ts b/packages/ui/src/components/sections/openchamber/types.ts
new file mode 100644
index 00000000..2e7303a7
--- /dev/null
+++ b/packages/ui/src/components/sections/openchamber/types.ts
@@ -0,0 +1,9 @@
+export type OpenChamberSection =
+ | 'visual'
+ | 'chat'
+ | 'shortcuts'
+ | 'sessions'
+ | 'git'
+ | 'github'
+ | 'notifications'
+ | 'voice';
diff --git a/packages/ui/src/components/sections/projects/ProjectsPage.tsx b/packages/ui/src/components/sections/projects/ProjectsPage.tsx
new file mode 100644
index 00000000..4fa7be92
--- /dev/null
+++ b/packages/ui/src/components/sections/projects/ProjectsPage.tsx
@@ -0,0 +1,215 @@
+import React from 'react';
+import { Input } from '@/components/ui/input';
+import { ButtonSmall } from '@/components/ui/button-small';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { cn } from '@/lib/utils';
+import { useProjectsStore } from '@/stores/useProjectsStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP } from '@/lib/projectMeta';
+import { RiCloseLine } from '@remixicon/react';
+import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
+
+export const ProjectsPage: React.FC = () => {
+ const projects = useProjectsStore((state) => state.projects);
+ const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
+ const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
+ const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
+
+ const selectedProject = React.useMemo(() => {
+ if (!selectedId) return null;
+ return projects.find((p) => p.id === selectedId) ?? null;
+ }, [projects, selectedId]);
+
+ React.useEffect(() => {
+ if (projects.length === 0) {
+ setSelectedId(null);
+ return;
+ }
+ if (selectedId && projects.some((p) => p.id === selectedId)) {
+ return;
+ }
+ setSelectedId(projects[0].id);
+ }, [projects, selectedId, setSelectedId]);
+
+ const [name, setName] = React.useState('');
+ const [icon, setIcon] = React.useState(null);
+ const [color, setColor] = React.useState(null);
+
+ React.useEffect(() => {
+ if (!selectedProject) {
+ setName('');
+ setIcon(null);
+ setColor(null);
+ return;
+ }
+ setName(selectedProject.label ?? '');
+ setIcon(selectedProject.icon ?? null);
+ setColor(selectedProject.color ?? null);
+ }, [selectedProject]);
+
+ const hasChanges = Boolean(selectedProject) && (
+ name.trim() !== (selectedProject?.label ?? '').trim()
+ || icon !== (selectedProject?.icon ?? null)
+ || color !== (selectedProject?.color ?? null)
+ );
+
+ const handleSave = React.useCallback(() => {
+ if (!selectedProject) return;
+ updateProjectMeta(selectedProject.id, { label: name.trim(), icon, color });
+ }, [color, icon, name, selectedProject, updateProjectMeta]);
+
+ if (!selectedProject) {
+ return (
+
+
+
No projects available.
+
+
+ );
+ }
+
+ const currentColorVar = color ? (COLOR_MAP[color] ?? null) : null;
+
+ return (
+
+
+
+ {/* Top Header & Actions */}
+
+
+
+ {selectedProject.label ?? 'Project Settings'}
+
+
+ {selectedProject.path}
+
+
+
+
+ {/* Identity Controls */}
+
+
+
+ {/* Name */}
+
+
+ Project Name
+
+
+ setName(e.target.value)}
+ placeholder="Project name"
+ className="h-7 min-w-0 w-full sm:max-w-[19rem]"
+ />
+
+
+
+ {/* Color */}
+
+
+ Accent Color
+
+
+ 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="None"
+ >
+
+
+ {PROJECT_COLORS.map((c) => (
+ setColor(c.key)}
+ className={cn(
+ 'h-7 w-7 rounded-md border transition-colors',
+ color === c.key
+ ? 'border-2 border-foreground ring-1 ring-[var(--primary-base)]/40'
+ : 'border-transparent hover:border-border/70'
+ )}
+ style={{ backgroundColor: c.cssVar }}
+ title={c.label}
+ />
+ ))}
+
+
+
+ {/* Icon */}
+
+
+ Project Icon
+
+
+ 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="None"
+ >
+
+
+ {PROJECT_ICONS.map((i) => {
+ const IconComponent = i.Icon;
+ return (
+ setIcon(i.key)}
+ className={cn(
+ 'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
+ icon === i.key
+ ? 'border-2 border-foreground bg-[var(--primary-base)]/10'
+ : 'border-transparent hover:border-border hover:bg-[var(--surface-muted)]'
+ )}
+ title={i.label}
+ >
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ Save Changes
+
+
+
+
+ {/* Worktree Group */}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx
new file mode 100644
index 00000000..ad792916
--- /dev/null
+++ b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import { useProjectsStore } from '@/stores/useProjectsStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { Button } from '@/components/ui/button';
+import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
+import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
+import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP } from '@/lib/projectMeta';
+import { cn } from '@/lib/utils';
+import { RiAddLine, RiFolderLine } from '@remixicon/react';
+import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
+import { sessionEvents } from '@/lib/sessionEvents';
+import { toast } from '@/components/ui';
+
+export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
+ const projects = useProjectsStore((state) => state.projects);
+ const addProject = useProjectsStore((state) => state.addProject);
+ const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
+ const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
+
+ const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
+ const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
+
+ const handleAddProject = React.useCallback(() => {
+ if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
+ sessionEvents.requestDirectoryDialog();
+ return;
+ }
+
+ import('@/lib/desktop')
+ .then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
+ .then((result) => {
+ if (result.success && result.path) {
+ const added = addProject(result.path, { id: result.projectId });
+ if (!added) {
+ toast.error('Failed to add project', {
+ description: 'Please select a valid directory.',
+ });
+ return;
+ }
+ setSelectedId(added.id);
+ } else if (result.error && result.error !== 'Directory selection cancelled') {
+ toast.error('Failed to select directory', {
+ description: result.error,
+ });
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to select directory:', error);
+ toast.error('Failed to select directory');
+ });
+ }, [addProject, setSelectedId, tauriIpcAvailable]);
+
+ React.useEffect(() => {
+ if (projects.length === 0) {
+ if (selectedId !== null) {
+ setSelectedId(null);
+ }
+ return;
+ }
+ if (selectedId && projects.some((p) => p.id === selectedId)) {
+ return;
+ }
+ setSelectedId(projects[0].id);
+ }, [projects, selectedId, setSelectedId]);
+
+ return (
+
+ Projects
+
+ Total {projects.length}
+ {!isVSCode && (
+
+
+
+ )}
+
+
+ }
+ >
+ {projects.map((project) => {
+ const selected = project.id === selectedId;
+ const Icon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
+ const color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
+ const icon = Icon
+ ? (
+
+ )
+ : (
+
+ );
+
+ return (
+
{
+ setSelectedId(project.id);
+ onItemSelect?.();
+ }}
+ />
+ );
+ })}
+
+ );
+};
diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx
index 0bae9789..6a413d2a 100644
--- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx
+++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx
@@ -2,7 +2,8 @@ import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore';
-import { Button } from '@/components/ui/button';
+import { useUIStore } from '@/stores/useUIStore';
+import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
@@ -11,7 +12,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui';
-import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine } from '@remixicon/react';
+import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine, RiInformationLine, RiEyeLine, RiEyeOffLine } from '@remixicon/react';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
@@ -140,6 +142,10 @@ export const ProvidersPage: React.FC = () => {
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
+ const hiddenModels = useUIStore((state) => state.hiddenModels);
+ const toggleHiddenModel = useUIStore((state) => state.toggleHiddenModel);
+ const hideAllModels = useUIStore((state) => state.hideAllModels);
+ const showAllModels = useUIStore((state) => state.showAllModels);
const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState>({});
const [authLoading, setAuthLoading] = React.useState(false);
@@ -243,7 +249,14 @@ export const ProvidersPage: React.FC = () => {
);
const unconnectedProviders = React.useMemo(
- () => availableProviders.filter((provider) => !connectedProviderIds.has(provider.id)),
+ () =>
+ availableProviders
+ .filter((provider) => !connectedProviderIds.has(provider.id))
+ .sort((a, b) => {
+ const labelA = (a.name || a.id).toLowerCase();
+ const labelB = (b.name || b.id).toLowerCase();
+ return labelA.localeCompare(labelB);
+ }),
[availableProviders, connectedProviderIds]
);
@@ -252,13 +265,8 @@ export const ProvidersPage: React.FC = () => {
return;
}
- if (!candidateProviderId && unconnectedProviders.length > 0) {
- setCandidateProviderId(unconnectedProviders[0].id);
- return;
- }
-
if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) {
- setCandidateProviderId(unconnectedProviders[0]?.id ?? '');
+ setCandidateProviderId('');
}
}, [selectedProviderId, candidateProviderId, unconnectedProviders]);
@@ -510,258 +518,248 @@ export const ProvidersPage: React.FC = () => {
if (isAddMode) {
return (
-
-
-
-
Connect provider
-
- Choose a provider to connect and set up its authentication.
-
-
-
-
-
-
Provider
-
- Select a provider that is not connected yet.
-
+
+
+
+
Connect Provider
- {availableLoading ? (
-
Loading providers…
- ) : availableError ? (
-
{availableError}
- ) : unconnectedProviders.length === 0 ? (
-
All available providers are already connected.
- ) : (
-
{
- setProviderDropdownOpen(open);
- if (!open) setProviderSearchQuery('');
- }}>
-
-
-
- {candidateProviderId
- ? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
- : "Select provider"}
-
-
-
-
- e.preventDefault()}
- >
- e.stopPropagation()}
- >
-
- setProviderSearchQuery(e.target.value)}
- onKeyDown={(e) => e.stopPropagation()}
- placeholder="Search providers..."
- className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground"
- autoFocus
- />
-
-
- {(() => {
- const filtered = unconnectedProviders.filter(p => {
- const query = providerSearchQuery.toLowerCase();
- return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
- });
- if (filtered.length === 0) {
- return No providers found
;
- }
- return filtered.map((provider) => (
- {
- setCandidateProviderId(provider.id);
- setProviderDropdownOpen(false);
- setProviderSearchQuery('');
- }}
- className="flex items-center justify-between"
+
+
+
Select Provider
+
+
+
+
+
Provider
+ {availableLoading ? (
+
Loading...
+ ) : availableError ? (
+
{availableError}
+ ) : unconnectedProviders.length === 0 ? (
+
All providers connected.
+ ) : (
+
{
+ setProviderDropdownOpen(open);
+ if (!open) setProviderSearchQuery('');
+ }}>
+
+
+
+ {candidateProviderId ? : null}
+
+ {candidateProviderId
+ ? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
+ : "Select provider"}
+
+
+
+
+
+ e.preventDefault()}
>
- {provider.name || provider.id}
- {candidateProviderId === provider.id && (
-
- )}
-
- ));
- })()}
-
-
-
- )}
-
-
- {candidateProviderId && (
-
-
Authentication
-
- {authLoading ? (
-
Loading authentication methods…
- ) : (
-
-
-
API key
-
-
- setApiKeyInputs((prev) => ({
- ...prev,
- [candidateProviderId]: event.target.value,
- }))
- }
- placeholder="sk-..."
- />
- handleSaveApiKey(candidateProviderId)}
- disabled={authBusyKey === `api:${candidateProviderId}`}
- className="h-8"
- >
- {authBusyKey === `api:${candidateProviderId}` ? 'Saving…' : 'Save key'}
-
-
-
- Keys are sent directly to OpenCode and never stored by OpenChamber.
-
-
-
- {(() => {
- const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
- const candidateOAuthMethods = candidateAuthMethods.filter(
- (method) => normalizeAuthType(method) === 'oauth'
- );
-
- if (candidateOAuthMethods.length === 0) {
- return null;
- }
-
- return (
-
- {candidateOAuthMethods.map((method, index) => {
- const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
- const codeKey = `${candidateProviderId}:${index}`;
- const isPending =
- pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index;
-
- return (
-
-
-
-
{methodLabel}
- {(method.description || method.help) && (
-
- {String(method.description || method.help)}
-
- )}
-
-
handleOAuthStart(candidateProviderId, index)}
- disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
- className="h-8"
+ e.stopPropagation()}
+ >
+
+ setProviderSearchQuery(e.target.value)}
+ onKeyDown={(e) => e.stopPropagation()}
+ placeholder="Search..."
+ className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground"
+ autoFocus
+ />
+
+
+ {(() => {
+ const filtered = unconnectedProviders.filter(p => {
+ const query = providerSearchQuery.toLowerCase();
+ return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
+ });
+ if (filtered.length === 0) {
+ return No providers found
;
+ }
+ return filtered.map((provider) => (
+ {
+ setCandidateProviderId(provider.id);
+ setProviderDropdownOpen(false);
+ setProviderSearchQuery('');
+ }}
+ className="flex items-center justify-between"
>
- Connect
-
-
-
- {oauthDetails[codeKey]?.instructions && (
-
- {oauthDetails[codeKey]?.instructions}
-
- )}
-
- {oauthDetails[codeKey]?.userCode && (
-
-
- handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}
- className="h-8"
- >
- Copy code
-
-
- )}
-
- {oauthDetails[codeKey]?.url && (
-
-
-
-
-
- Open link
-
-
-
handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}
- className="h-8"
- >
- Copy link
-
-
-
- )}
-
- {isPending && (
-
-
- setOauthCodes((prev) => ({
- ...prev,
- [codeKey]: event.target.value,
- }))
- }
- placeholder="Authorization code (if required)"
- />
- handleOAuthComplete(candidateProviderId, index)}
- disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
- className="h-8"
- >
- {authBusyKey === `oauth-complete:${candidateProviderId}:${index}`
- ? 'Saving…'
- : 'Complete'}
-
-
- )}
-
- );
- })}
-
- );
- })()}
+
+
+ {provider.name || provider.id}
+
+ {candidateProviderId === provider.id && (
+
+ )}
+
+ ));
+ })()}
+
+
+
+ )}
- )}
+
- )}
+
+ {candidateProviderId && (
+
+
+
Authentication
+
+
+ {authLoading ? (
+
Loading authentication methods...
+ ) : (
+
+
+
+ API Key
+
+
+
+
+
+ Keys are sent directly to OpenCode and never stored by OpenChamber.
+
+
+
+
+
+ setApiKeyInputs((prev) => ({
+ ...prev,
+ [candidateProviderId]: event.target.value,
+ }))
+ }
+ placeholder="sk-..."
+ className="flex-1 font-mono text-xs"
+ />
+ handleSaveApiKey(candidateProviderId)}
+ disabled={authBusyKey === `api:${candidateProviderId}`}
+ >
+ {authBusyKey === `api:${candidateProviderId}` ? 'Saving...' : 'Save Key'}
+
+
+
+
+ {(() => {
+ const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
+ const candidateOAuthMethods = candidateAuthMethods.filter(
+ (method) => normalizeAuthType(method) === 'oauth'
+ );
+
+ if (candidateOAuthMethods.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {candidateOAuthMethods.map((method, index) => {
+ const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
+ const codeKey = `${candidateProviderId}:${index}`;
+ const isPending =
+ pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index;
+
+ return (
+
+
+
+
{methodLabel}
+ {(method.description || method.help) && (
+
+ {String(method.description || method.help)}
+
+ )}
+
+
handleOAuthStart(candidateProviderId, index)}
+ disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
+ >
+ Connect
+
+
+
+ {oauthDetails[codeKey]?.instructions && (
+
+ {oauthDetails[codeKey]?.instructions}
+
+ )}
+
+ {oauthDetails[codeKey]?.userCode && (
+
+
+ handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code
+
+ )}
+
+ {oauthDetails[codeKey]?.url && (
+
+
+
+ window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open
+ handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy
+
+
+ )}
+
+ {isPending && (
+
+
+ setOauthCodes((prev) => ({
+ ...prev,
+ [codeKey]: event.target.value,
+ }))
+ }
+ placeholder="Paste authorization code"
+ className="font-mono text-xs"
+ />
+ handleOAuthComplete(candidateProviderId, index)}
+ disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
+ >
+ {authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? 'Saving...' : 'Complete'}
+
+
+ )}
+
+ );
+ })}
+
+ );
+ })()}
+
+ )}
+
+ )}
);
@@ -780,7 +778,6 @@ export const ProvidersPage: React.FC = () => {
}
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
-
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
@@ -794,269 +791,322 @@ export const ProvidersPage: React.FC = () => {
return (
-
-
-
-
-
- {selectedProvider.name || selectedProvider.id}
-
-
-
- Provider ID: {selectedProvider.id}
-
-
+
-
-
-
Authentication
- {selectedProviderId !== ADD_PROVIDER_ID && (
-
+
+
+
+ {selectedProvider.name || selectedProvider.id}
+
+
+ {selectedProvider.id}
+
+
+
+
+ {/* Authentication */}
+
+
+
Authentication
+ setShowAuthPanel((prev) => !prev)}
- className="h-8"
>
{showAuthPanel ? 'Hide' : 'Reconnect'}
-
- )}
-
+
+
- {!showAuthPanel && selectedProviderId !== ADD_PROVIDER_ID ? (
-
- Connected. Use Reconnect to update credentials.
-
- ) : authLoading ? (
-
Loading authentication methods…
- ) : (
-
-
-
API key
-
-
- setApiKeyInputs((prev) => ({
- ...prev,
- [selectedProvider.id]: event.target.value,
- }))
- }
- placeholder="sk-..."
- />
-
handleSaveApiKey(selectedProvider.id)}
- disabled={authBusyKey === `api:${selectedProvider.id}`}
- className="h-8"
- >
- {authBusyKey === `api:${selectedProvider.id}` ? 'Saving…' : 'Save key'}
-
+
+ {!showAuthPanel ? (
+
+
+ Connected
+ · Use Reconnect to update credentials
-
- Keys are sent directly to OpenCode and never stored by OpenChamber.
-
-
+ ) : authLoading ? (
+
Loading authentication methods...
+ ) : (
+
+
+
+ API Key
+
+
+
+
+
+ Keys are sent directly to OpenCode and never stored by OpenChamber.
+
+
+
+
+
+ setApiKeyInputs((prev) => ({
+ ...prev,
+ [selectedProvider.id]: event.target.value,
+ }))
+ }
+ placeholder="sk-..."
+ className="flex-1 font-mono text-xs"
+ />
+ handleSaveApiKey(selectedProvider.id)}
+ disabled={authBusyKey === `api:${selectedProvider.id}`}
+ >
+ {authBusyKey === `api:${selectedProvider.id}` ? 'Saving...' : 'Save Key'}
+
+
+
- {oauthAuthMethods.length > 0 && (
-
- {oauthAuthMethods.map((method, index) => {
- const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
- const codeKey = `${selectedProvider.id}:${index}`;
- const isPending =
- pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index;
+ {oauthAuthMethods.length > 0 && (
+
+ {oauthAuthMethods.map((method, index) => {
+ const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
+ const codeKey = `${selectedProvider.id}:${index}`;
+ const isPending =
+ pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index;
- return (
-
-
-
-
{methodLabel}
- {(method.description || method.help) && (
-
- {String(method.description || method.help)}
+ return (
+
+
+
+
{methodLabel}
+ {(method.description || method.help) && (
+
+ {String(method.description || method.help)}
+
+ )}
+
+
handleOAuthStart(selectedProvider.id, index)}
+ disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
+ >
+ Connect
+
+
+
+ {oauthDetails[codeKey]?.instructions && (
+
+ {oauthDetails[codeKey]?.instructions}
+
+ )}
+
+ {oauthDetails[codeKey]?.userCode && (
+
+
+ handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code
+
+ )}
+
+ {oauthDetails[codeKey]?.url && (
+
+
+
+ window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open
+ handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy
+
+
+ )}
+
+ {isPending && (
+
+
+ setOauthCodes((prev) => ({
+ ...prev,
+ [codeKey]: event.target.value,
+ }))
+ }
+ placeholder="Paste authorization code"
+ className="font-mono text-xs"
+ />
+ handleOAuthComplete(selectedProvider.id, index)}
+ disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
+ >
+ {authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? 'Saving...' : 'Complete'}
+
)}
-
handleOAuthStart(selectedProvider.id, index)}
- disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
- className="h-8"
- >
- Connect
-
-
+ );
+ })}
+
+ )}
+
+ )}
+
+
- {oauthDetails[codeKey]?.instructions && (
-
- {oauthDetails[codeKey]?.instructions}
-
- )}
+ {/* Connection Details */}
+
+
+
Connection Details
+
- {oauthDetails[codeKey]?.userCode && (
-
-
- handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}
- className="h-8"
- >
- Copy code
-
-
- )}
+
+
+
+ {selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) ? (
+
+ Configured in: {[
+ selectedSources.auth.exists ? 'auth credentials' : null,
+ selectedSources.user.exists ? 'user config' : null,
+ selectedSources.project.exists ? 'project config' : null,
+ selectedSources.custom?.exists ? 'custom config' : null,
+ ].filter(Boolean).join(', ')}
+
+ ) : (
+ No active configuration source
+ )}
+
- {oauthDetails[codeKey]?.url && (
-
-
-
-
-
- Open link
-
-
-
handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}
- className="h-8"
- >
- Copy link
-
+
handleDisconnectProvider(selectedProvider.id)}
+ disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
+ >
+ {authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting...' : 'Disconnect'}
+
+
+
+
+
+ {/* Models */}
+
+
+
+ Available Models
+ {providerModels.length > 0 && (
+
+ ({providerModels.length})
+
+ )}
+
+
+ {
+ const allIds = providerModels
+ .map((model) => (typeof model?.id === 'string' ? model.id : ''))
+ .filter((id) => id.length > 0);
+ hideAllModels(selectedProvider.id, allIds);
+ }}
+ >
+ Hide all
+
+ showAllModels(selectedProvider.id)}
+ >
+ Show all
+
+
+
+
+
+
+
+ setModelQuery(event.target.value)}
+ placeholder="Filter models..."
+ className="h-7 pl-8 w-full"
+ />
+
+
+ {filteredModels.length === 0 ? (
+ No models match this filter.
+ ) : (
+
+ {filteredModels.map((model) => {
+ const modelId = typeof model?.id === 'string' ? model.id : '';
+ const modelName = typeof model?.name === 'string' ? model.name : modelId;
+ const metadata = modelId ? getModelMetadata(selectedProvider.id, modelId) as ModelMetadata | undefined : undefined;
+ const isHidden = hiddenModels.some(
+ (item) => item.providerID === selectedProvider.id && item.modelID === modelId
+ );
+
+ const contextTokens = formatTokens(metadata?.limit?.context);
+ const outputTokens = formatTokens(metadata?.limit?.output);
+
+ const capabilityIcons: Array<{ key: string; icon: typeof RiToolsLine; label: string }> = [];
+ if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: 'Tool calling' });
+ if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: 'Reasoning' });
+ if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: 'Image input' });
+
+ return (
+
+
+
+ {modelName}
+
+
+ {(contextTokens || outputTokens) && (
+
+ {contextTokens ? `${contextTokens} ctx` : ''}
+ {contextTokens && outputTokens ? ' · ' : ''}
+ {outputTokens ? `${outputTokens} out` : ''}
+
+ )}
+ {capabilityIcons.length > 0 && (
+
+ {capabilityIcons.map(({ key, icon: Icon, label }) => (
+
+
+
+ ))}
-
- )}
-
-
- {isPending && (
-
-
- setOauthCodes((prev) => ({
- ...prev,
- [codeKey]: event.target.value,
- }))
- }
- placeholder="Paste authorization code"
- />
- handleOAuthComplete(selectedProvider.id, index)}
- disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
- className="h-8"
- >
- {authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`
- ? 'Saving…'
- : 'Complete'}
-
-
- )}
+ )}
+
toggleHiddenModel(selectedProvider.id, modelId)}
+ className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]/50"
+ title={isHidden ? 'Show model in selectors' : 'Hide model from selectors'}
+ aria-label={isHidden ? 'Show model' : 'Hide model'}
+ >
+ {isHidden ? : }
+
+
+
);
})}
)}
-
- )}
-
-
-
-
Connection
- {selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) && (
-
- Configured in: {[
- selectedSources.auth.exists ? 'auth credentials' : null,
- selectedSources.user.exists ? 'user config' : null,
- selectedSources.project.exists ? 'project config' : null,
- selectedSources.custom?.exists ? 'custom config' : null,
- ].filter(Boolean).join(', ')}
-
- )}
-
handleDisconnectProvider(selectedProvider.id)}
- disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
- className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
- >
- {authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting…' : 'Disconnect provider'}
-
-
-
-
-
-
Models
-
- Browse and filter models exposed by this provider.
-
+
-
-
setModelQuery(event.target.value)}
- placeholder="Filter models..."
- />
-
-
- {filteredModels.length === 0 ? (
-
No models match this filter.
- ) : (
- filteredModels.map((model) => {
- const modelId = typeof model?.id === 'string' ? model.id : '';
- const modelName = typeof model?.name === 'string' ? model.name : modelId;
- const metadata = modelId ? getModelMetadata(selectedProvider.id, modelId) as ModelMetadata | undefined : undefined;
-
- const contextTokens = formatTokens(metadata?.limit?.context);
- const outputTokens = formatTokens(metadata?.limit?.output);
-
- const capabilityIcons: Array<{ key: string; icon: typeof RiToolsLine; label: string }> = [];
- if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: 'Tool calling' });
- if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: 'Reasoning' });
- if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: 'Image input' });
-
- return (
-
-
- {modelName}
-
- {(contextTokens || outputTokens) && (
-
- {contextTokens ? `${contextTokens} ctx` : ''}
- {contextTokens && outputTokens ? ' · ' : ''}
- {outputTokens ? `${outputTokens} out` : ''}
-
- )}
- {capabilityIcons.length > 0 && (
-
- {capabilityIcons.map(({ key, icon: Icon, label }) => (
-
-
-
- ))}
-
- )}
-
- );
- })
- )}
-
-
);
diff --git a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
index be85bc41..7a6eac98 100644
--- a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
+++ b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
@@ -1,15 +1,36 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
-import { Button } from '@/components/ui/button';
+import { ButtonSmall } from '@/components/ui/button-small';
import { useConfigStore } from '@/stores/useConfigStore';
-import { useDeviceInfo } from '@/lib/device';
-import { isVSCodeRuntime } from '@/lib/desktop';
+import { useProjectsStore } from '@/stores/useProjectsStore';
import { RiAddLine, RiStackLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
+import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
+import { opencodeClient } from '@/lib/opencode/client';
const ADD_PROVIDER_ID = '__add_provider__';
+interface ProviderSourceInfo {
+ exists: boolean;
+ path?: string | null;
+}
+
+interface ProviderSources {
+ auth: ProviderSourceInfo;
+ user: ProviderSourceInfo;
+ project: ProviderSourceInfo;
+ custom?: ProviderSourceInfo;
+}
+
+const getCurrentDirectory = (): string | null => {
+ const dir = opencodeClient.getDirectory();
+ if (typeof dir === 'string' && dir.trim().length > 0) {
+ return dir.trim();
+ }
+ return null;
+};
+
interface ProvidersSidebarProps {
onItemSelect?: () => void;
}
@@ -18,22 +39,80 @@ export const ProvidersSidebar: React.FC
= ({ onItemSelect
const providers = useConfigStore((state) => state.providers);
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
- const { isMobile } = useDeviceInfo();
+ const activeProjectId = useProjectsStore((s) => s.activeProjectId);
+ const [sourcesByProvider, setSourcesByProvider] = React.useState>({});
+ const directory = React.useMemo(() => {
+ // tie refresh to active project changes (directory is stored in the client)
+ void activeProjectId;
+ return getCurrentDirectory();
+ }, [activeProjectId]);
- const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
+ React.useEffect(() => {
+ if (providers.length === 0) {
+ setSourcesByProvider({});
+ return;
+ }
- const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
+ let cancelled = false;
+
+ const loadAllSources = async () => {
+ const tasks = providers.map(async (provider) => {
+ try {
+ const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
+ const response = await fetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ return;
+ }
+ const payload = await response.json().catch(() => null);
+ const sources = (payload?.sources ?? payload?.data?.sources) as ProviderSources | undefined;
+ if (!sources) {
+ return;
+ }
+ if (cancelled) {
+ return;
+ }
+ setSourcesByProvider((prev) => ({
+ ...prev,
+ [provider.id]: sources,
+ }));
+ } catch {
+ // ignore
+ }
+ });
+
+ await Promise.all(tasks);
+ };
+
+ void loadAllSources();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [directory, providers]);
+
+ const bgClass = 'bg-background';
+
+ const projectProviders = React.useMemo(() => {
+ return providers.filter((p) => Boolean(sourcesByProvider[p.id]?.project?.exists));
+ }, [providers, sourcesByProvider]);
+
+ const userProviders = React.useMemo(() => {
+ return providers.filter((p) => !sourcesByProvider[p.id]?.project?.exists);
+ }, [providers, sourcesByProvider]);
return (
-
+
+
Providers
+
Total {providers.length}
-
{
setSelectedProvider(ADD_PROVIDER_ID);
onItemSelect?.();
@@ -41,8 +120,8 @@ export const ProvidersSidebar: React.FC = ({ onItemSelect
aria-label="Connect provider"
title="Connect provider"
>
-
-
+
+
@@ -54,40 +133,81 @@ export const ProvidersSidebar: React.FC
= ({ onItemSelect
Check your OpenCode configuration
) : (
- providers.map((provider) => {
- const modelCount = Array.isArray(provider.models) ? provider.models.length : 0;
- const isSelected = provider.id === selectedProviderId;
+ <>
+ {userProviders.length > 0 && (
+ <>
+
+ User Providers
+
+ {userProviders.map((provider) => (
+
{
+ setSelectedProvider(provider.id);
+ onItemSelect?.();
+ }}
+ />
+ ))}
+ >
+ )}
- return (
-
-
{
- setSelectedProvider(provider.id);
- onItemSelect?.();
- }}
- className="flex min-w-0 flex-1 items-center gap-2 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
- tabIndex={0}
- >
-
-
- {provider.name || provider.id}
-
-
- {modelCount}
-
-
-
- );
- })
+ {projectProviders.length > 0 && (
+ <>
+ 0 ? 'pt-3' : 'pt-2')}>
+ Project Providers
+
+ {projectProviders.map((provider) => (
+ {
+ setSelectedProvider(provider.id);
+ onItemSelect?.();
+ }}
+ />
+ ))}
+ >
+ )}
+ >
)}
);
};
+
+const ProviderListItem: React.FC<{
+ provider: { id: string; name?: string; models?: unknown[] };
+ selectedProviderId: string;
+ onSelect: () => void;
+}> = ({ provider, selectedProviderId, onSelect }) => {
+ const modelCount = Array.isArray(provider.models) ? provider.models.length : 0;
+ const isSelected = provider.id === selectedProviderId;
+
+ return (
+
+
+
+
+ {provider.name || provider.id}
+
+
+ {modelCount}
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx b/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx
index e5a7ae8b..40f29008 100644
--- a/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx
+++ b/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx
@@ -37,7 +37,7 @@ export const SettingsPageLayout: React.FC = ({
>
diff --git a/packages/ui/src/components/sections/shared/SettingsProjectSelector.tsx b/packages/ui/src/components/sections/shared/SettingsProjectSelector.tsx
new file mode 100644
index 00000000..77b1d627
--- /dev/null
+++ b/packages/ui/src/components/sections/shared/SettingsProjectSelector.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { RiArrowDownSLine, RiFolderLine } from '@remixicon/react';
+import { useProjectsStore } from '@/stores/useProjectsStore';
+import { isVSCodeRuntime } from '@/lib/desktop';
+import { cn } from '@/lib/utils';
+
+const formatProjectLabel = (label: string): string => {
+ return label.replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
+};
+
+export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
+ const projects = useProjectsStore((state) => state.projects);
+ const activeProjectId = useProjectsStore((state) => state.activeProjectId);
+ const setActiveProject = useProjectsStore((state) => state.setActiveProject);
+
+ const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
+
+ const sortedProjects = React.useMemo(() => {
+ return [...projects].sort((a, b) => (a.label || a.path).localeCompare(b.label || b.path));
+ }, [projects]);
+
+ const activeProject = React.useMemo(() => {
+ if (sortedProjects.length === 0) {
+ return null;
+ }
+ return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0];
+ }, [activeProjectId, sortedProjects]);
+
+ if (isVSCode || sortedProjects.length === 0) {
+ return null;
+ }
+
+ const rawLabel = activeProject?.label && activeProject.label.trim().length > 0
+ ? activeProject.label
+ : (activeProject?.path.split('/').filter(Boolean).pop() || activeProject?.path || 'Project');
+ const label = formatProjectLabel(rawLabel);
+
+ return (
+
+
+
+
+
+ {label}
+
+
+
+
+ {
+ if (!value) return;
+ setActiveProject(value);
+ }}
+ >
+ {sortedProjects.map((project) => {
+ const raw = project.label?.trim()
+ ? project.label.trim()
+ : (project.path.split('/').filter(Boolean).pop() || project.path);
+ const itemLabel = formatProjectLabel(raw);
+ return (
+
+ {itemLabel}
+
+ );
+ })}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx b/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx
index d2f0b0ec..147161d9 100644
--- a/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx
+++ b/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx
@@ -68,7 +68,7 @@ export const SettingsSidebarItem: React.FC
= ({
return (
= ({
footer,
children,
className,
+ variant = 'sidebar',
}) => {
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
- // Desktop app: transparent for blur effect
- // VS Code: bg-background (same as page content)
- // Web/mobile: bg-sidebar
- const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
+ const scrollRef = React.useRef
(null);
+ const [showTopShadow, setShowTopShadow] = React.useState(false);
+ const [showBottomShadow, setShowBottomShadow] = React.useState(false);
+
+ const bgClass = variant === 'background'
+ ? 'bg-background'
+ : (isVSCode ? 'bg-background' : 'bg-sidebar');
+
+ const bgVar = bgClass === 'bg-background'
+ ? 'var(--surface-background)'
+ : 'var(--surface-muted)';
+
+ const updateScrollShadows = React.useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) {
+ setShowTopShadow(false);
+ setShowBottomShadow(false);
+ return;
+ }
+ const canScroll = el.scrollHeight > el.clientHeight + 1;
+ if (!canScroll) {
+ setShowTopShadow(false);
+ setShowBottomShadow(false);
+ return;
+ }
+ setShowTopShadow(el.scrollTop > 1);
+ setShowBottomShadow(el.scrollTop + el.clientHeight < el.scrollHeight - 1);
+ }, []);
+
+ React.useEffect(() => {
+ updateScrollShadows();
+ }, [children, updateScrollShadows]);
+
+ React.useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const onScroll = () => updateScrollShadows();
+ el.addEventListener('scroll', onScroll, { passive: true });
+ return () => el.removeEventListener('scroll', onScroll);
+ }, [updateScrollShadows]);
return (
= ({
>
{header}
-
- {children}
-
+
+
}
+ outerClassName="flex-1 min-h-0"
+ className="space-y-0.5 px-3 py-2 overflow-x-hidden"
+ >
+ {children}
+
+
+ {showTopShadow && (
+
+ )}
+ {showBottomShadow && (
+
+ )}
+
{footer}
diff --git a/packages/ui/src/components/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx
index 64b57fa4..20ce25a6 100644
--- a/packages/ui/src/components/sections/skills/SkillsPage.tsx
+++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx
@@ -1,12 +1,10 @@
import React from 'react';
-import type { Extension } from '@codemirror/state';
-import { EditorView } from '@codemirror/view';
-import { Button } from '@/components/ui/button';
+import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
-import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiSaveLine, RiUser3Line } from '@remixicon/react';
+import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
Select,
@@ -23,10 +21,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
-import { AnimatedTabs } from '@/components/ui/animated-tabs';
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
-import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
-import { useThemeSystem } from '@/contexts/useThemeSystem';
import {
SKILL_LOCATION_OPTIONS,
locationLabel,
@@ -35,13 +30,15 @@ import {
type SkillLocationValue,
} from './skillLocations';
-const LazyCodeMirrorEditor = React.lazy(async () => {
- const module = await import('@/components/ui/CodeMirrorEditor');
- return { default: module.CodeMirrorEditor };
-});
+export interface SkillsPageProps {
+ view?: 'installed' | 'catalog';
+}
-export const SkillsPage: React.FC = () => {
- const { currentTheme } = useThemeSystem();
+const SkillsCatalogStandalone: React.FC = () => (
+ {}} showModeTabs={false} />
+);
+
+const SkillsInstalledPage: React.FC = () => {
const {
selectedSkillName,
getSkillByName,
@@ -58,155 +55,47 @@ export const SkillsPage: React.FC = () => {
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
- type SkillsMode = 'manual' | 'external';
- const [mode, setMode] = React.useState('manual');
-
- React.useEffect(() => {
- if (!isNewSkill && mode !== 'manual') {
- setMode('manual');
- }
- }, [isNewSkill, mode]);
-
React.useEffect(() => {
if (!hasStaleSelection) {
return;
}
- // Clear persisted selection if it points to a non-existent skill.
setSelectedSkill(null);
}, [hasStaleSelection, setSelectedSkill]);
- const modeTabs = isNewSkill ? (
-
- ) : null;
-
const [draftName, setDraftName] = React.useState('');
const [draftScope, setDraftScope] = React.useState('user');
const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode');
const [description, setDescription] = React.useState('');
const [instructions, setInstructions] = React.useState('');
const [supportingFiles, setSupportingFiles] = React.useState([]);
- const [pendingFiles, setPendingFiles] = React.useState([]); // For new skills
+ const [pendingFiles, setPendingFiles] = React.useState([]);
const [isSaving, setIsSaving] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
- // Track original values to detect changes
const [originalDescription, setOriginalDescription] = React.useState('');
const [originalInstructions, setOriginalInstructions] = React.useState('');
- // File dialog state
const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false);
const [newFileName, setNewFileName] = React.useState('');
const [newFileContent, setNewFileContent] = React.useState('');
- const [editingFilePath, setEditingFilePath] = React.useState(null); // null = adding, string = editing
+ const [editingFilePath, setEditingFilePath] = React.useState(null);
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
- const [originalFileContent, setOriginalFileContent] = React.useState(''); // Track original for change detection
+ const [originalFileContent, setOriginalFileContent] = React.useState('');
const [deleteFilePath, setDeleteFilePath] = React.useState(null);
const [isDeletingFile, setIsDeletingFile] = React.useState(false);
- const [instructionsEditorHeight, setInstructionsEditorHeight] = React.useState(320);
- const [instructionsLanguage, setInstructionsLanguage] = React.useState(null);
- const [supportingFileLanguage, setSupportingFileLanguage] = React.useState(null);
-
- React.useEffect(() => {
- let cancelled = false;
-
- const loadInstructionsLanguage = async () => {
- const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
- if (cancelled) return;
- setInstructionsLanguage(languageByExtension('SKILL.md'));
- };
-
- void loadInstructionsLanguage();
- return () => {
- cancelled = true;
- };
- }, []);
-
- React.useEffect(() => {
- let cancelled = false;
-
- const loadSupportingFileLanguage = async () => {
- const targetPath = newFileName.trim();
- if (!targetPath) {
- setSupportingFileLanguage(null);
- return;
- }
-
- const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
- if (cancelled) return;
- setSupportingFileLanguage(languageByExtension(targetPath));
- };
-
- void loadSupportingFileLanguage();
- return () => {
- cancelled = true;
- };
- }, [newFileName]);
-
- const instructionsEditorExtensions = React.useMemo(() => {
- const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
- if (instructionsLanguage) {
- extensions.push(instructionsLanguage);
- }
- return extensions;
- }, [currentTheme, instructionsLanguage]);
-
- const supportingFileEditorExtensions = React.useMemo(() => {
- const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
- if (supportingFileLanguage) {
- extensions.push(supportingFileLanguage);
- }
- return extensions;
- }, [currentTheme, supportingFileLanguage]);
-
- const handleStartInstructionsResize = React.useCallback((event: React.MouseEvent) => {
- event.preventDefault();
- const startY = event.clientY;
- const startHeight = instructionsEditorHeight;
-
- const onMouseMove = (moveEvent: MouseEvent) => {
- const deltaY = moveEvent.clientY - startY;
- const viewportMax = typeof window !== 'undefined' ? Math.floor(window.innerHeight * 0.75) : 800;
- const nextHeight = Math.max(220, Math.min(viewportMax, startHeight + deltaY));
- setInstructionsEditorHeight(nextHeight);
- };
-
- const onMouseUp = () => {
- window.removeEventListener('mousemove', onMouseMove);
- window.removeEventListener('mouseup', onMouseUp);
- };
-
- window.addEventListener('mousemove', onMouseMove);
- window.addEventListener('mouseup', onMouseUp);
- }, [instructionsEditorHeight]);
- // Detect if skill-level fields have changed
const hasSkillChanges = isNewSkill
? (draftName.trim() !== '' || description.trim() !== '' || instructions.trim() !== '' || pendingFiles.length > 0)
: (description !== originalDescription || instructions !== originalInstructions);
- // Detect if file content has changed
const hasFileChanges = editingFilePath
? newFileContent !== originalFileContent
- : newFileName.trim() !== ''; // For new files, just need a name
+ : newFileName.trim() !== '';
- // Load skill details when selection changes
React.useEffect(() => {
- if (mode === 'external') {
- return;
- }
-
const loadSkillDetails = async () => {
if (isNewSkill && skillDraft) {
- // Prefill from draft (for new or duplicated skills)
setDraftName(skillDraft.name || '');
setDraftScope(skillDraft.scope || 'user');
setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode');
@@ -221,7 +110,6 @@ export const SkillsPage: React.FC = () => {
try {
const detail = await getSkillDetail(selectedSkillName);
if (detail) {
- // Get actual content from the API response
const md = detail.sources.md;
setDescription(md.description || '');
setInstructions(md.instructions || '');
@@ -238,7 +126,7 @@ export const SkillsPage: React.FC = () => {
};
loadSkillDetails();
- }, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail, mode]);
+ }, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
const handleSave = async () => {
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
@@ -248,7 +136,6 @@ export const SkillsPage: React.FC = () => {
return;
}
- // Validate skill name format
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
toast.error('Skill name must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen');
return;
@@ -259,7 +146,6 @@ export const SkillsPage: React.FC = () => {
return;
}
- // Check for duplicate name when creating new skill
if (isNewSkill && skills.some((s) => s.name === skillName)) {
toast.error('A skill with this name already exists');
return;
@@ -274,7 +160,6 @@ export const SkillsPage: React.FC = () => {
instructions: instructions.trim() || undefined,
scope: isNewSkill ? draftScope : undefined,
source: isNewSkill ? draftSource : undefined,
- // Include pending files when creating new skill
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
};
@@ -282,14 +167,13 @@ export const SkillsPage: React.FC = () => {
if (isNewSkill) {
success = await createSkill(config);
if (success) {
- setSkillDraft(null); // Clear draft after successful creation
- setPendingFiles([]); // Clear pending files
- setSelectedSkill(skillName); // Select the newly created skill
+ setSkillDraft(null);
+ setPendingFiles([]);
+ setSelectedSkill(skillName);
}
} else {
success = await updateSkill(skillName, config);
if (success) {
- // Update original values to reflect saved state
setOriginalDescription(description.trim());
setOriginalInstructions(instructions.trim());
}
@@ -320,7 +204,6 @@ export const SkillsPage: React.FC = () => {
setEditingFilePath(filePath);
setNewFileName(filePath);
- // For new skills, get content from pending files
if (isNewSkill) {
const pendingFile = pendingFiles.find(f => f.path === filePath);
const content = pendingFile?.content || '';
@@ -330,7 +213,6 @@ export const SkillsPage: React.FC = () => {
return;
}
- // For existing skills, load content from server
if (!selectedSkillName) return;
setIsLoadingFile(true);
@@ -359,16 +241,13 @@ export const SkillsPage: React.FC = () => {
const filePath = newFileName.trim();
const isEditing = editingFilePath !== null;
- // For new skills, add/update pending files
if (isNewSkill) {
if (isEditing) {
- // Update existing pending file
setPendingFiles(prev => prev.map(f =>
f.path === editingFilePath ? { path: filePath, content: newFileContent } : f
));
toast.success(`File "${filePath}" updated`);
} else {
- // Check for duplicate
if (pendingFiles.some(f => f.path === filePath)) {
toast.error('A file with this name already exists');
return;
@@ -381,7 +260,6 @@ export const SkillsPage: React.FC = () => {
return;
}
- // For existing skills, write directly to disk
if (!selectedSkillName) {
toast.error('No skill selected');
return;
@@ -394,7 +272,6 @@ export const SkillsPage: React.FC = () => {
toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`);
setIsFileDialogOpen(false);
setEditingFilePath(null);
- // Refresh skill details to get updated file list
const detail = await getSkillDetail(selectedSkillName);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
@@ -405,14 +282,12 @@ export const SkillsPage: React.FC = () => {
};
const handleDeleteFile = (filePath: string) => {
- // For new skills, remove from pending files
if (isNewSkill) {
setPendingFiles(prev => prev.filter(f => f.path !== filePath));
toast.success(`File "${filePath}" removed`);
return;
}
- // For existing skills, delete from disk
if (!selectedSkillName) {
return;
}
@@ -443,12 +318,6 @@ export const SkillsPage: React.FC = () => {
setIsDeletingFile(false);
};
- if (isNewSkill && mode === 'external') {
- return ;
- }
-
-
- // Show empty state when nothing is selected or selection is stale
if ((!selectedSkillName && !skillDraft) || hasStaleSelection) {
return (
@@ -473,225 +342,185 @@ export const SkillsPage: React.FC = () => {
return (
-
- {isNewSkill ? modeTabs : null}
+
- {/* Header */}
-
-
- {isNewSkill ? 'New Skill' : selectedSkillName}
-
- {selectedSkill && (
-
- {locationLabel(selectedSkill.scope, selectedSkill.source)} skill
- {selectedSkill.source === 'claude' && ' (Claude-compatible)'}
-
- )}
-
-
- {/* Basic Information */}
-
-
-
Basic Information
-
- Configure skill identity and description
-
-
-
- {isNewSkill && (
-
-
- Skill Name & Location
-
-
-
setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
- placeholder="skill-name"
- className="flex-1 text-foreground placeholder:text-muted-foreground"
- />
-
{
- const next = locationPartsFrom(v as SkillLocationValue);
- setDraftScope(next.scope);
- setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
- }}
- >
-
- {draftScope === 'user' ? (
-
- ) : (
-
- )}
- {draftSource === 'agents' ? : null}
- {locationLabel(draftScope, draftSource)}
-
-
- {SKILL_LOCATION_OPTIONS.map((option) => (
-
-
-
- {option.scope === 'user' ? : }
- {option.source === 'agents' ? : null}
- {option.label}
-
-
{option.description}
-
-
- ))}
-
-
-
-
- Lowercase letters, numbers, and hyphens only. Cannot start or end with hyphen.
-
-
- )}
-
-
-
-
- {/* Instructions */}
-
-
-
Instructions
-
- Detailed instructions for the agent when this skill is loaded
-
-
-
-
-
setInstructions(e.target.value)}
- placeholder="Step-by-step instructions, guidelines, or reference content..."
- rows={12}
- className="h-full border-0 rounded-none font-mono typography-meta resize-none"
- />
+ {/* Header */}
+
+
+
+ {isNewSkill ? 'New Skill' : selectedSkillName}
+ {selectedSkill?.source === 'claude' && (
+
+ Claude-compatible
+
)}
- >
-
-
-
-
-
-
-
- {/* Supporting Files */}
-
-
-
-
Supporting Files
-
- Reference documentation, scripts, or templates
+
+
+ {selectedSkill ? `${locationLabel(selectedSkill.scope, selectedSkill.source)} skill` : 'Configure a new skill'}
-
-
- Add File
-
- {(() => {
- // For new skills, show pending files
- const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
-
- if (filesToShow.length === 0) {
- return (
-
- {isNewSkill ? 'No files yet. Use "Add File" to include reference materials.' : 'No supporting files. Use "Add File" to include reference materials.'}
-
- );
- }
-
- return (
-
- {filesToShow.map((file) => (
-
handleEditFile(file.path)}
- >
-
-
- {file.path}
- {isNewSkill && (
-
- pending
-
- )}
-
-
{
- e.stopPropagation();
- handleDeleteFile(file.path);
+ {/* Basic Information */}
+
+
+
+ Basic Information
+
+
+
+
+
+ {isNewSkill && (
+
+
Skill Name & Location
+
Lowercase, numbers, hyphens
+
+
setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
+ placeholder="skill-name"
+ className="h-7 w-40 px-2"
+ />
+
{
+ const next = locationPartsFrom(v as SkillLocationValue);
+ setDraftScope(next.scope);
+ setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
-
-
+
+ {draftScope === 'user' ? (
+
+ ) : (
+
+ )}
+ {draftSource === 'agents' ? : null}
+ {locationLabel(draftScope, draftSource)}
+
+
+ {SKILL_LOCATION_OPTIONS.map((option) => (
+
+
+
+ {option.scope === 'user' ? : }
+ {option.source === 'agents' ? : null}
+ {option.label}
+
+
{option.description}
+
+
+ ))}
+
+
- ))}
-
- );
- })()}
-
+
+ )}
+
+
+
Description *
+
The agent uses this to decide when to load the skill
+
+
+
+
+
+
+
+ {/* Instructions */}
+
+
+
+ Instructions
+
+
+
+
+
+
+ {/* Supporting Files */}
+
+
+
+ Supporting Files
+
+
+ Add File
+
+
+
+
+ {(() => {
+ const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
+
+ if (filesToShow.length === 0) {
+ return (
+
+ No supporting files. Use "Add File" to include reference materials.
+
+ );
+ }
+
+ return (
+
+ {filesToShow.map((file) => (
+
handleEditFile(file.path)}
+ >
+
+ {file.path}
+ {isNewSkill && (
+
+ pending
+
+ )}
+ {
+ e.stopPropagation();
+ handleDeleteFile(file.path);
+ }}
+ >
+
+
+
+ ))}
+
+ );
+ })()}
+
+
+
+ {/* Save action */}
+
+
+ {isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
+
+
- {/* Save Button */}
-
-
-
- {isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
-
{/* Add/Edit File Dialog */}
@@ -711,15 +540,14 @@ export const SkillsPage: React.FC = () => {
- setDeleteFilePath(null)}
disabled={isDeletingFile}
- className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
-
-
+
+
Delete
@@ -730,7 +558,7 @@ export const SkillsPage: React.FC = () => {
setIsFileDialogOpen(open);
if (!open) setEditingFilePath(null);
}}>
-
+
{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}
@@ -742,7 +570,7 @@ export const SkillsPage: React.FC = () => {
Loading file content...
) : (
-
+
File Path
@@ -751,7 +579,7 @@ export const SkillsPage: React.FC = () => {
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="example.md or docs/reference.txt"
- className="text-foreground placeholder:text-muted-foreground"
+ className="text-foreground placeholder:text-muted-foreground focus-visible:ring-[var(--primary-base)]"
disabled={editingFilePath !== null}
/>
{!editingFilePath && (
@@ -764,46 +592,36 @@ export const SkillsPage: React.FC = () => {
Content
-
- setNewFileContent(e.target.value)}
- placeholder="File content..."
- className="h-full border-0 rounded-none font-mono typography-meta resize-none"
- />
- )}
- >
-
-
-
+
)}
-
-
+ {
setIsFileDialogOpen(false);
setEditingFilePath(null);
}}
- className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
-
+
{editingFilePath ? 'Save Changes' : 'Create File'}
-
);
};
+
+export const SkillsPage: React.FC
= ({ view = 'installed' }) => {
+ return view === 'catalog' ? : ;
+};
diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx
index 5316b48a..01564c44 100644
--- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx
+++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx
@@ -1,8 +1,9 @@
import React, { useMemo } from 'react';
-import { Button } from '@/components/ui/button';
+import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
+import { isMobileDeviceViaCSS } from '@/lib/device';
import {
Dialog,
DialogContent,
@@ -19,11 +20,9 @@ import {
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
-import { useUIStore } from '@/stores/useUIStore';
-import { useDeviceInfo } from '@/lib/device';
-import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
interface SkillsSidebarProps {
@@ -35,6 +34,7 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
const [renameNewName, setRenameNewName] = React.useState('');
const [deleteDialogSkill, setDeleteDialogSkill] = React.useState(null);
const [isDeletePending, setIsDeletePending] = React.useState(false);
+ const [openMenuSkill, setOpenMenuSkill] = React.useState(null);
const {
selectedSkillName,
@@ -43,20 +43,12 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
setSkillDraft,
createSkill,
deleteSkill,
- loadSkills,
getSkillDetail,
} = useSkillsStore();
- const { setSidebarOpen } = useUIStore();
- const { isMobile } = useDeviceInfo();
+ // Skills are loaded by the Settings shell when this page is active.
- const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
-
- React.useEffect(() => {
- loadSkills();
- }, [loadSkills]);
-
- const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
+ const bgClass = 'bg-background';
const handleCreateNew = () => {
// Generate unique name
@@ -73,9 +65,7 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
setSelectedSkill(newName);
onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
+
};
const handleDeleteSkill = async (skill: DiscoveredSkill) => {
@@ -125,9 +115,7 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
});
setSelectedSkill(newName);
- if (isMobile) {
- setSidebarOpen(false);
- }
+
};
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
@@ -214,18 +202,18 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
return (
-
+
+
Skills
+
Total {skills.length}
-
-
-
+
+
@@ -258,13 +246,13 @@ export const SkillsSidebar: React.FC
= ({ onItemSelect }) =>
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
+
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
+ isMenuOpen={openMenuSkill === skill.name}
+ onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/>
))}
@@ -277,13 +265,13 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
+
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
+ isMenuOpen={openMenuSkill === skill.name}
+ onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/>
))}
>
@@ -309,13 +297,13 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
+
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
+ isMenuOpen={openMenuSkill === skill.name}
+ onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/>
))}
@@ -328,13 +316,13 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
- if (isMobile) {
- setSidebarOpen(false);
- }
+
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
+ isMenuOpen={openMenuSkill === skill.name}
+ onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/>
))}
>
@@ -359,14 +347,14 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
- setDeleteDialogSkill(null)}
disabled={isDeletePending}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
-
+
Delete
@@ -395,13 +383,13 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) =>
}}
/>
- setRenameDialogSkill(null)}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
-
+
Rename
@@ -419,6 +407,8 @@ interface SkillListItemProps {
onDelete: () => void;
onRename: () => void;
onDuplicate: () => void;
+ isMenuOpen: boolean;
+ onMenuOpenChange: (open: boolean) => void;
}
const SkillListItem: React.FC = ({
@@ -428,13 +418,20 @@ const SkillListItem: React.FC = ({
onDelete,
onRename,
onDuplicate,
+ isMenuOpen,
+ onMenuOpenChange,
}) => {
+ const isMobile = isMobileDeviceViaCSS();
return (
{
+ e.preventDefault();
+ onMenuOpenChange(true);
+ } : undefined}
>
= ({
-
+
-
-
+
= ({ open, onOpen
- Catalog name
+ Catalog name
setLabel(e.target.value)} placeholder="e.g. Team Skills" />
-
Repository
+
Repository
{
@@ -261,7 +261,7 @@ export const AddCatalogDialog: React.FC
= ({ open, onOpen
-
Optional subpath
+
Optional subpath
{
@@ -274,28 +274,26 @@ export const AddCatalogDialog: React.FC
= ({ open, onOpen
{identityOptions.length > 0 && !isVSCodeRuntime() ? (
-
-
Authentication required
-
- Select a Git identity (SSH key) that can access this repository.
-
-
- setGitIdentityId(v)}>
-
- {identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}
-
-
- {identityOptions.map((id) => (
-
- {id.name}
-
- ))}
-
-
-
-
- Configure identities in Settings → Git Identities.
+
+
+ Authentication required
+ Select a Git identity (SSH key)
+
setGitIdentityId(v)}>
+
+ {identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}
+
+
+ {identityOptions.map((id) => (
+
+ {id.name}
+
+ ))}
+
+
+
+ Configure identities in Settings - Git Identities.
+
) : null}
@@ -313,25 +311,24 @@ export const AddCatalogDialog: React.FC
= ({ open, onOpen
- onOpenChange(false)}>
+ onOpenChange(false)}>
Cancel
-
-
+ void handleScan()}
disabled={isScanning || !source.trim()}
className="gap-2"
>
- {isScanning ? 'Scanning…' : 'Scan'}
-
-
+ void handleAdd()}
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
>
Add catalog
-
+
diff --git a/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx
index c25148ef..2c5fd79a 100644
--- a/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx
+++ b/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx
@@ -8,8 +8,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
+import { ButtonSmall } from '@/components/ui/button-small';
import {
Select,
SelectContent,
@@ -73,8 +73,8 @@ export const InstallConflictsDialog: React.FC
= ({
{conflicts.length} conflict(s)
- setAll('skip')}>Skip all
- setAll('overwrite')}>Overwrite all
+ setAll('skip')}>Skip all
+ setAll('overwrite')}>Overwrite all
@@ -82,7 +82,7 @@ export const InstallConflictsDialog: React.FC = ({
{conflicts.map((conflict) => (
{conflict.skillName}
@@ -95,7 +95,7 @@ export const InstallConflictsDialog: React.FC
= ({
value={decisions[conflict.skillName] || 'skip'}
onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))}
>
-