feat: redesign settings pages to match canonical flat UI patterns (#493)

* refactor(settings): new IA shell + projects section + skills catalog discoverability

* chore(settings): split providers list by scope; show user before project

* fix: navigation flow in mobile Settings

* feat: redesign settings pages to use modern elevated surface patterns

* feat: replace helper text with tooltips in settings

* ui: redesign update dialog and fix external link routing

- Restructures UpdateDialog to focus on changelog readability with a wider max-w-4xl canvas
- Highlights @username contributor mentions with theme primary color
- Strips excessive vertical padding and right-aligns compact action buttons
- Disables streamdown's internal link safety dialog in favor of direct Tauri shell routing

* feat: refactor Git identities into dedicated Git settings page

* feat: unify sidebar background styling across VS Code and web/mobile

* fix: adjust button styling and layout for mobile settings pages

* feat: add MCP settings page and sidebar

* feat: hide models in provider view (thanks to @nguyenngothuong)

* feat: add "Add new provider" option to model selector dropdown

* fix: local evroc logo + provider dropdown icons

* fix: increase width of provider menu

* fix: dark theme background color for better contrast

* feat: update @opencode-ai/sdk dependency to v1.2.10

* fix: restore session sorting to only use updated time

* fix: added settings for sessions deletion dialog

* fix: adjust padding on settings pages for better layout

* fix: standardize select dropdown height across UI

* fix: agent selector UI and notification settings

* fix: remove redundant helper text from settings pages

* fix: update UI layout for description fields

* fix: remove border-none and shadow-none from textarea classes

* fix: enable context menu on sidebar items

* feat: refactor UI controls and layout patterns across settings pages

* fix: use headerless blocks when page title already provides context

* fix: remove subtask option from command settings

* fix: refactor mcp page settings

* fix: reduce spacing in skills configuration pages

* feat: refactor voice settings

* feat: refactor settings sidebar sections
This commit is contained in:
Bohdan Triapitsyn
2026-02-24 03:28:30 +02:00
committed by GitHub
parent d2d39c48ac
commit d2358c2c03
90 changed files with 8062 additions and 7128 deletions
@@ -733,10 +733,11 @@ export const SimpleMarkdownRenderer: React.FC<{
content: string;
className?: string;
variant?: MarkdownVariant;
disableLinkSafety?: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
mermaidControls?: MermaidControlOptions;
allowMermaidWheelZoom?: boolean;
}> = ({ content, className, variant = 'assistant', onShowPopup, mermaidControls, allowMermaidWheelZoom = false }) => {
}> = ({ content, className, variant = 'assistant', disableLinkSafety, onShowPopup, mermaidControls, allowMermaidWheelZoom = false }) => {
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
useMermaidInlineInteractions({
@@ -767,6 +768,8 @@ export const SimpleMarkdownRenderer: React.FC<{
plugins={streamdownPlugins}
mermaid={mermaidOptions}
components={streamdownComponents}
// @ts-expect-error Streamdown type missing linkSafety in older minor
linkSafety={disableLinkSafety ? { enabled: false } : undefined}
>
{content}
</Streamdown>
@@ -1,6 +1,7 @@
import React from 'react';
import type { ComponentType } from 'react';
import {
RiAddLine,
RiAiAgentLine,
RiArrowDownSLine,
RiArrowGoBackLine,
@@ -187,6 +188,8 @@ const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
});
const ADD_PROVIDER_ID = '__add_provider__';
const formatTokens = (value?: number | null) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return '—';
@@ -297,6 +300,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
settingsDefaultVariant,
settingsDefaultAgent,
setProvider,
setSelectedProvider,
setModel,
setCurrentVariant,
getCurrentModelVariants,
@@ -362,7 +366,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
addRecentEffort,
isModelSelectorOpen,
setModelSelectorOpen,
setSettingsDialogOpen,
setSettingsPage,
} = useUIStore();
const hiddenModels = useUIStore((state) => state.hiddenModels);
// Separate state for agent selector to avoid conflict with model selector
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
@@ -393,6 +400,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Use global state for model selector (allows Ctrl+M shortcut)
const agentMenuOpen = isModelSelectorOpen;
const setAgentMenuOpen = setModelSelectorOpen;
const openAddProviderSettings = React.useCallback(() => {
setSelectedProvider(ADD_PROVIDER_ID);
setSettingsPage('providers');
setSettingsDialogOpen(true);
setAgentMenuOpen(false);
closeMobilePanel();
}, [setSelectedProvider, setSettingsPage, setSettingsDialogOpen, setAgentMenuOpen, closeMobilePanel]);
const [desktopModelQuery, setDesktopModelQuery] = React.useState('');
const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0);
const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
@@ -535,6 +549,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const currentProvider = getCurrentProvider();
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
const visibleProviders = React.useMemo(() => {
return providers
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const visibleModels = providerModels.filter((model: ProviderModel) => {
const modelId = typeof model?.id === 'string' ? model.id : '';
return !hiddenModels.some(
(item) => item.providerID === String(provider.id) && item.modelID === modelId
);
});
return { ...provider, models: visibleModels };
})
.filter((provider) => provider.models.length > 0);
}, [providers, hiddenModels]);
const currentMetadata =
currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined;
const currentCapabilityIcons = getCapabilityIcons(currentMetadata);
@@ -1439,7 +1468,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
if (!isCompact) return null;
const normalizedQuery = mobileModelQuery.trim();
const filteredProviders = providers
const filteredProviders = visibleProviders
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const matchesProvider = normalizedQuery.length === 0
@@ -2092,7 +2121,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
});
// Filter providers and their models
const filteredProviders = providers
const filteredProviders = visibleProviders
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const filteredModels = providerModels.filter((model: ProviderModel) => {
@@ -2218,6 +2247,26 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{/* Scrollable content */}
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
<div className="p-1">
<div
role="button"
tabIndex={0}
onClick={openAddProviderSettings}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openAddProviderSettings();
}
}}
className="typography-meta group flex items-center gap-1 rounded-md px-2 py-1.5 cursor-pointer hover:bg-interactive-hover/50"
>
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
<RiAddLine className="h-4 w-4 -mr-0.5" />
</span>
<span className="font-medium text-foreground">Add new provider</span>
</div>
<DropdownMenuSeparator />
{!hasResults && (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
No models found
+3 -2
View File
@@ -15,7 +15,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiFolderAddLine, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiMore2Fill, RiPencilLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiSettings3Line, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react';
import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiFileTextLine, RiFolder6Line, RiFolderAddLine, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiMore2Fill, RiPencilLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiSettings3Line, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react';
import { DiffIcon } from '@/components/icons/DiffIcon';
import { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
@@ -31,6 +31,7 @@ import { useDeviceInfo } from '@/lib/device';
import { cn, hasModifier, formatDirectoryName } from '@/lib/utils';
import { useDiffFileCount } from '@/components/views/DiffView';
import { McpDropdown, McpDropdownContent } from '@/components/mcp/McpDropdown';
import { McpIcon } from '@/components/icons/McpIcon';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
@@ -1058,7 +1059,7 @@ export const Header: React.FC = () => {
}
base.push(
{ value: 'usage', label: 'Usage', icon: RiTimerLine },
{ value: 'mcp', label: 'MCP', icon: RiCommandLine }
{ value: 'mcp', label: 'MCP', icon: McpIcon as unknown as RemixiconComponentType }
);
return base;
}, [isDesktopApp]);
@@ -1,167 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
import type { SidebarSection } from '@/constants/sidebar';
import { RiArrowLeftSLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
import { GitIdentitiesPage } from '@/components/sections/git-identities/GitIdentitiesPage';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
}
const SETTINGS_SECTIONS = (() => {
const filtered = SIDEBAR_SECTIONS.filter(section => section.id !== 'sessions');
const settingsSection = filtered.find(s => s.id === 'settings');
const otherSections = filtered.filter(s => s.id !== 'settings');
return settingsSection ? [settingsSection, ...otherSections] : filtered;
})();
/**
* Mobile-only settings dialog using MobileOverlayPanel.
* Desktop uses SettingsView rendered inline in MainLayout.
*/
export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = React.useState<SidebarSection>('settings');
const [showPageContent, setShowPageContent] = React.useState(false);
React.useEffect(() => {
if (isOpen) {
setActiveTab('settings');
setShowPageContent(false);
}
}, [isOpen]);
const handleTabChange = React.useCallback((tab: SidebarSection) => {
setActiveTab(tab);
setShowPageContent(false);
}, []);
const handleItemSelect = React.useCallback(() => {
setShowPageContent(true);
}, []);
const renderSidebarContent = () => {
if (activeTab === 'settings') {
return null;
}
const content = (() => {
switch (activeTab) {
case 'agents':
return <AgentsSidebar />;
case 'commands':
return <CommandsSidebar />;
case 'providers':
return <ProvidersSidebar />;
case 'git-identities':
return <GitIdentitiesSidebar />;
default:
return null;
}
})();
return <div onClick={handleItemSelect}>{content}</div>;
};
const renderPageContent = () => {
switch (activeTab) {
case 'agents':
return <AgentsPage />;
case 'commands':
return <CommandsPage />;
case 'providers':
return <ProvidersPage />;
case 'git-identities':
return <GitIdentitiesPage />;
case 'settings':
return <OpenChamberPage />;
default:
return null;
}
};
const mainContent = (
<div className="flex flex-col h-full overflow-hidden">
{/* Tab bar */}
<div className="flex flex-wrap items-center gap-1 border-b border-border/40 bg-background/95 px-3 py-1.5">
{SETTINGS_SECTIONS.map(({ id, label, icon: Icon }) => {
const isActive = activeTab === id;
const PhosphorIcon = Icon as React.ComponentType<{ className?: string; weight?: string }>;
return (
<button
key={id}
onClick={() => handleTabChange(id)}
className={cn(
'flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium whitespace-nowrap',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={isActive}
aria-label={label}
>
<PhosphorIcon className="h-5 w-5" weight="regular" />
</button>
);
})}
</div>
{/* Content area - mobile drill-down pattern */}
<div className="flex flex-1 overflow-hidden">
{activeTab !== 'settings' && !showPageContent && (
<div className="w-full overflow-hidden bg-sidebar">
<ErrorBoundary>{renderSidebarContent()}</ErrorBoundary>
</div>
)}
{(activeTab === 'settings' || showPageContent) && (
<div className="w-full flex-1 overflow-hidden bg-background">
<ErrorBoundary>{renderPageContent()}</ErrorBoundary>
</div>
)}
</div>
</div>
);
return (
<MobileOverlayPanel
open={isOpen}
onClose={onClose}
title="Settings"
className="max-w-full"
contentMaxHeightClassName="h-[min(80dvh,720px)]"
renderHeader={(closeButton) => (
<div className="flex items-center justify-between px-3 py-2 border-b border-border/40">
<div className="relative flex flex-1 items-center justify-center">
{showPageContent && (
<Button
variant="ghost"
size="icon"
onClick={() => setShowPageContent(false)}
className="absolute left-0 h-6 w-6 flex-shrink-0"
>
<RiArrowLeftSLine className="h-5 w-5" />
<span className="sr-only">Back to sidebar</span>
</Button>
)}
<span className="typography-ui-header">Settings</span>
</div>
{closeButton}
</div>
)}
>
{mainContent}
</MobileOverlayPanel>
);
};
File diff suppressed because it is too large Load Diff
@@ -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,12 +20,10 @@ import {
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk/v2';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
interface AgentsSidebarProps {
@@ -106,6 +105,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
const [confirmActionAgent, setConfirmActionAgent] = React.useState<Agent | null>(null);
const [confirmActionType, setConfirmActionType] = React.useState<'delete' | 'reset' | null>(null);
const [isConfirmActionPending, setIsConfirmActionPending] = React.useState(false);
const [openMenuAgent, setOpenMenuAgent] = React.useState<string | null>(null);
const {
selectedAgentName,
@@ -117,16 +117,11 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents,
} = useAgentsStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
loadAgents();
}, [loadAgents]);
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const bgClass = 'bg-background';
const handleCreateNew = () => {
// Generate unique name
@@ -143,9 +138,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
setSelectedAgent(newName);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteAgent = async (agent: Agent) => {
@@ -226,9 +218,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
});
setSelectedAgent(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleOpenRenameDialog = (agent: Agent) => {
@@ -329,18 +319,18 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Agents</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span>
<Button
type="button"
<ButtonSmall
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="size-4" />
</Button>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
@@ -366,13 +356,13 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => {
setSelectedAgent(agent.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onReset={() => handleResetAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon}
isMenuOpen={openMenuAgent === agent.name}
onMenuOpenChange={(open) => setOpenMenuAgent(open ? agent.name : null)}
/>
))}
</>
@@ -400,14 +390,14 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => {
setSelectedAgent(agent.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(agent)}
onDelete={() => handleDeleteAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon}
isMenuOpen={openMenuAgent === agent.name}
onMenuOpenChange={(open) => setOpenMenuAgent(open ? agent.name : null)}
/>
))}
</SidebarGroup>
@@ -422,14 +412,14 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => {
setSelectedAgent(agent.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(agent)}
onDelete={() => handleDeleteAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon}
isMenuOpen={openMenuAgent === agent.name}
onMenuOpenChange={(open) => setOpenMenuAgent(open ? agent.name : null)}
/>
))}
</>
@@ -456,14 +446,13 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={closeConfirmActionDialog}
disabled={isConfirmActionPending}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}>
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
</ButtonLarge>
@@ -492,13 +481,12 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
}}
/>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={() => setRenameDialogAgent(null)}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleRenameAgent}>
Rename
</ButtonLarge>
@@ -518,6 +506,8 @@ interface AgentListItemProps {
onRename?: () => void;
onDuplicate: () => void;
getAgentModeIcon: (mode?: string) => React.ReactNode;
isMenuOpen: boolean;
onMenuOpenChange: (open: boolean) => void;
}
const AgentListItem: React.FC<AgentListItemProps> = ({
@@ -529,15 +519,22 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
onRename,
onDuplicate,
getAgentModeIcon,
isMenuOpen,
onMenuOpenChange,
}) => {
const extAgent = agent as Agent & { scope?: AgentScope };
const isMobile = isMobileDeviceViaCSS();
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
>
<div className="flex min-w-0 flex-1 items-center">
<button
@@ -564,15 +561,14 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
)}
</button>
<DropdownMenu>
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
<ButtonSmall
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</ButtonSmall>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && (
@@ -57,6 +57,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}) => {
const { providers, modelsMetadata } = useConfigStore();
const isMobile = useUIStore(state => state.isMobile);
const hiddenModels = useUIStore(state => state.hiddenModels);
const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore();
const { favoriteModelsList, recentModelsList } = useModelLists();
const { isMobile: deviceIsMobile } = useDeviceInfo();
@@ -77,11 +78,23 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}, [allowedProviderIds]);
const visibleProviders = React.useMemo(() => {
if (!allowedProviderSet) {
return providers;
}
return providers.filter((provider) => allowedProviderSet.has(String(provider.id)));
}, [providers, allowedProviderSet]);
const baseProviders = allowedProviderSet
? providers.filter((provider) => allowedProviderSet.has(String(provider.id)))
: providers;
return baseProviders
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const filteredModels = providerModels.filter((model: ProviderModel) => {
const modelId = typeof model?.id === 'string' ? model.id : '';
return !hiddenModels.some(
(hidden) => hidden.providerID === String(provider.id) && hidden.modelID === modelId
);
});
return { ...provider, models: filteredModels };
})
.filter((provider) => provider.models.length > 0);
}, [providers, allowedProviderSet, hiddenModels]);
const closeMobilePanel = () => setIsMobilePanelOpen(false);
const toggleMobileProviderExpansion = (provId: string) => {
@@ -250,7 +263,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<MobileOverlayPanel
open={isMobilePanelOpen}
onClose={closeMobilePanel}
title="Select Model"
title="Select model"
>
<div className="space-y-1">
{/* Favorites Section for Mobile */}
@@ -499,24 +512,24 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<div className={cn(
'flex items-center gap-2 px-2 rounded-lg bg-interactive-selection/20 border border-border/20 cursor-pointer hover:bg-interactive-hover/30 h-6 w-fit',
'border-input data-[placeholder]:text-muted-foreground flex items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none hover:bg-interactive-hover data-[state=open]:bg-interactive-active h-6 w-fit',
className
)}>
{providerId ? (
<>
<ProviderLogo
providerId={providerId}
className="h-3 w-3 flex-shrink-0"
className="h-3.5 w-3.5 flex-shrink-0"
/>
<RiPencilAiLine className="h-3 w-3 text-primary/60 hidden" />
</>
) : (
<RiPencilAiLine className="h-3 w-3 text-muted-foreground" />
<RiPencilAiLine className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="typography-micro font-medium whitespace-nowrap">
<span className="typography-ui-label font-normal whitespace-nowrap text-foreground">
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || 'Not selected')}
</span>
<RiArrowDownSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col" align="start">
@@ -48,9 +48,23 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
<MobileOverlayPanel
open={isMobilePanelOpen}
onClose={closeMobilePanel}
title="Select Agent"
title="Select agent"
>
<div className="space-y-1">
<button
type="button"
className={cn(
'flex w-full items-center justify-between rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left',
!agentName ? 'bg-primary/10 text-primary' : 'text-foreground'
)}
onClick={() => {
handleAgentChange('');
closeMobilePanel();
}}
>
<span className={cn('typography-meta', !agentName ? 'font-medium' : 'text-muted-foreground')}>Not selected</span>
{!agentName && <div className="h-2 w-2 rounded-full bg-primary" />}
</button>
{agents.map((agent) => {
const isSelected = agent.name === agentName;
@@ -81,17 +95,6 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
</button>
);
})}
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left"
onClick={() => {
handleAgentChange('');
closeMobilePanel();
}}
>
<span className="typography-meta text-muted-foreground">No agent (optional)</span>
</button>
</div>
</MobileOverlayPanel>
);
@@ -131,6 +134,12 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[300px]">
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange('')}
>
<span className="text-muted-foreground">Not selected</span>
</DropdownMenuItem>
{agents.map((agent) => (
<DropdownMenuItem
key={agent.name}
@@ -140,12 +149,6 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
<span className="font-medium">{agent.name}</span>
</DropdownMenuItem>
))}
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange('')}
>
<span className="text-muted-foreground">No agent (optional)</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -1,20 +1,19 @@
import React from 'react';
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 { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { RiInformationLine, RiSaveLine, RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react';
import { RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react';
import { ModelSelector } from '../agents/ModelSelector';
import { AgentSelector } from './AgentSelector';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export const CommandsPage: React.FC = () => {
@@ -29,7 +28,6 @@ export const CommandsPage: React.FC = () => {
const [agent, setAgent] = React.useState('');
const [model, setModel] = React.useState('');
const [template, setTemplate] = React.useState('');
const [subtask, setSubtask] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const initialStateRef = React.useRef<{
draftName: string;
@@ -38,27 +36,22 @@ export const CommandsPage: React.FC = () => {
agent: string;
model: string;
template: string;
subtask: boolean;
} | null>(null);
React.useEffect(() => {
if (isNewCommand && commandDraft) {
// Prefill from draft (for new or duplicated commands)
const draftNameValue = commandDraft.name || '';
const draftScopeValue = commandDraft.scope || 'user';
const descriptionValue = commandDraft.description || '';
const agentValue = commandDraft.agent || '';
const modelValue = commandDraft.model || '';
const templateValue = commandDraft.template || '';
const subtaskValue = commandDraft.subtask || false;
setDraftName(draftNameValue);
setDraftScope(draftScopeValue);
setDescription(descriptionValue);
setAgent(agentValue);
setModel(modelValue);
setTemplate(templateValue);
setSubtask(subtaskValue);
initialStateRef.current = {
draftName: draftNameValue,
@@ -67,20 +60,16 @@ export const CommandsPage: React.FC = () => {
agent: agentValue,
model: modelValue,
template: templateValue,
subtask: subtaskValue,
};
} else if (selectedCommand) {
const descriptionValue = selectedCommand.description || '';
const agentValue = selectedCommand.agent || '';
const modelValue = selectedCommand.model || '';
const templateValue = selectedCommand.template || '';
const subtaskValue = selectedCommand.subtask || false;
setDescription(descriptionValue);
setAgent(agentValue);
setModel(modelValue);
setTemplate(templateValue);
setSubtask(subtaskValue);
initialStateRef.current = {
draftName: '',
@@ -89,7 +78,6 @@ export const CommandsPage: React.FC = () => {
agent: agentValue,
model: modelValue,
template: templateValue,
subtask: subtaskValue,
};
}
}, [selectedCommand, isNewCommand, selectedCommandName, commands, commandDraft]);
@@ -109,10 +97,8 @@ export const CommandsPage: React.FC = () => {
if (agent !== initial.agent) return true;
if (model !== initial.model) return true;
if (template !== initial.template) return true;
if (subtask !== initial.subtask) return true;
return false;
}, [agent, description, draftName, draftScope, isNewCommand, model, subtask, template]);
}, [agent, description, draftName, draftScope, isNewCommand, model, template]);
const handleSave = async () => {
const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim();
@@ -127,7 +113,6 @@ export const CommandsPage: React.FC = () => {
return;
}
// Check for duplicate name when creating new command
if (isNewCommand && commands.some((cmd) => cmd.name === commandName)) {
toast.error('A command with this name already exists');
return;
@@ -145,7 +130,6 @@ export const CommandsPage: React.FC = () => {
agent: trimmedAgent === '' ? null : trimmedAgent,
model: trimmedModel === '' ? null : trimmedModel,
template: trimmedTemplate,
subtask,
scope: isNewCommand ? draftScope : undefined,
};
@@ -153,7 +137,7 @@ export const CommandsPage: React.FC = () => {
if (isNewCommand) {
success = await createCommand(config);
if (success) {
setCommandDraft(null); // Clear draft after successful creation
setCommandDraft(null);
}
} else {
success = await updateCommand(commandName, config);
@@ -186,231 +170,167 @@ export const CommandsPage: React.FC = () => {
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{isNewCommand ? 'New Command' : `/${selectedCommandName}`}
</h1>
</div>
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2>
<p className="typography-meta text-muted-foreground/80">
Configure command identity and metadata
{/* Header */}
<div className="mb-4 flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{isNewCommand ? 'New Command' : `/${selectedCommandName}`}
</h2>
<p className="typography-meta text-muted-foreground truncate">
{isNewCommand ? 'Configure a new slash command' : 'Edit command settings'}
</p>
</div>
</div>
{isNewCommand && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Command Name & Scope
</label>
<div className="flex items-center gap-2">
<div className="flex items-center flex-1">
<span className="typography-ui-label text-muted-foreground mr-1">/</span>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="command-name"
className="flex-1 text-foreground placeholder:text-muted-foreground"
/>
{/* Identity */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Identity
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewCommand && (
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Command Name</span>
</div>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}>
<SelectTrigger className="!h-9 w-auto gap-1.5">
{draftScope === 'user' ? (
<RiUser3Line className="h-4 w-4" />
) : (
<RiFolderLine className="h-4 w-4" />
)}
<span className="capitalize">{draftScope}</span>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<div className="flex items-center">
<span className="typography-ui-label text-muted-foreground mr-1">/</span>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="command-name"
className="h-7 w-40 px-2"
/>
</div>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}>
<SelectTrigger className="w-fit min-w-[100px]">
<SelectValue placeholder="Scope" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user">
<div className="flex items-center gap-2">
<RiUser3Line className="h-4 w-4" />
<span>User</span>
<RiUser3Line className="h-3.5 w-3.5" />
<span>Global</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
</div>
</SelectItem>
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
</SelectItem>
<SelectItem value="project">
<div className="flex items-center gap-2">
<RiFolderLine className="h-4 w-4" />
<RiFolderLine className="h-3.5 w-3.5" />
<span>Project</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div className="py-1.5">
<span className="typography-ui-label text-foreground">Description</span>
<div className="mt-1.5">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this command do?"
rows={2}
className="w-full resize-none min-h-[60px] bg-transparent"
/>
</div>
</div>
)}
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Description
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this command do?"
rows={3}
/>
</div>
</section>
</div>
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Model & Agent Configuration</h2>
<p className="typography-meta text-muted-foreground/80">
Configure model and agent for command execution
</p>
{/* Execution Context */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Execution Context
</h3>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Agent
</label>
<AgentSelector
agentName={agent}
onChange={(agentName: string) => setAgent(agentName)}
/>
<p className="typography-meta text-muted-foreground">
Agent to execute this command (optional)
</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Model
</label>
<ModelSelector
providerId={model ? model.split('/')[0] : ''}
modelId={model ? model.split('/')[1] : ''}
onChange={(providerId: string, modelId: string) => {
if (providerId && modelId) {
setModel(`${providerId}/${modelId}`);
} else {
setModel('');
}
}}
/>
<p className="typography-meta text-muted-foreground">
Default model for this command (optional)
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2 cursor-pointer">
<Checkbox
checked={subtask}
onChange={(checked) => setSubtask(checked)}
/>
Force Subagent Invocation
</label>
<div className="flex items-center gap-2">
<p className="typography-meta text-muted-foreground">
Force command to run in a subagent context
</p>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
When enabled, this command will always execute in a subagent context,<br/>
even if triggered from main agent.<br/>
Useful for isolating command logic and maintaining clean separation of concerns.
</TooltipContent>
</Tooltip>
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Override Agent</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<AgentSelector
agentName={agent}
onChange={(agentName: string) => setAgent(agentName)}
/>
</div>
</div>
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Override Model</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector
providerId={model ? model.split('/')[0] : ''}
modelId={model ? model.split('/')[1] : ''}
onChange={(providerId: string, modelId: string) => {
if (providerId && modelId) {
setModel(`${providerId}/${modelId}`);
} else {
setModel('');
}
}}
/>
</div>
</div>
</section>
</div>
{/* Command Template */}
<div className="mb-2">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Command Template
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<Textarea
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder={`Your command template here...\n\nUse $ARGUMENTS to reference user input.\nUse !\`shell command\` to inject shell output.\nUse @filename to include file contents.`}
rows={12}
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
/>
</section>
<div className="mt-2 px-2">
<p className="typography-meta text-muted-foreground">
<code className="text-foreground">$ARGUMENTS</code> user input &middot;{' '}
<code className="text-foreground">!`cmd`</code> shell output &middot;{' '}
<code className="text-foreground">@file</code> file contents
</p>
</div>
</div>
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Command Template</h2>
<p className="typography-meta text-muted-foreground/80">
Define the prompt template for this command. Use $ARGUMENTS for user input.
</p>
</div>
<Textarea
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder={`Your command template here...
Use $ARGUMENTS to reference user input.
Use !\`shell command\` to inject shell output.
Use @filename to include file contents.`}
rows={12}
className="font-mono typography-meta"
/>
<div className="typography-meta text-muted-foreground/80 space-y-1">
<p className="font-medium">Template Features:</p>
<ul className="list-disc list-inside space-y-0.5 ml-2">
<li className="flex items-center gap-2">
<code className="bg-muted px-1 rounded">$ARGUMENTS</code>
<span>- User input after command</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3 w-3 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Replaced with everything the user types after the command name.<br/>
Example: "/deploy staging" makes $ARGUMENTS = "staging"
</TooltipContent>
</Tooltip>
</li>
<li className="flex items-center gap-2">
<code className="bg-muted px-1 rounded">!`command`</code>
<span>- Inject shell command output</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3 w-3 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Executes shell command and replaces this placeholder with its output.<br/>
Example: !`git branch --show-current` gets current branch name
</TooltipContent>
</Tooltip>
</li>
<li className="flex items-center gap-2">
<code className="bg-muted px-1 rounded">@filename</code>
<span>- Include file contents</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3 w-3 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Replaces with the full contents of the specified file.<br/>
Example: @package.json includes the package.json content in the prompt
</TooltipContent>
</Tooltip>
</li>
</ul>
</div>
{}
<div className="flex justify-end border-t border-border/40 pt-4">
<Button
size="sm"
variant="default"
{/* Save action */}
<div className="px-2 py-1">
<ButtonSmall
onClick={handleSave}
disabled={isSaving || !isDirty}
className="gap-2 h-6 px-2 text-xs w-fit"
size="xs"
className="!font-normal"
>
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</ButtonSmall>
</div>
</div>
</div>
</ScrollableOverlay>
);
@@ -1,8 +1,9 @@
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 { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
import {
Dialog,
DialogContent,
@@ -20,11 +21,9 @@ import {
import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiRestartLine, RiEditLine } from '@remixicon/react';
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
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 { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
interface CommandsSidebarProps {
onItemSelect?: () => void;
@@ -36,6 +35,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
const [confirmActionCommand, setConfirmActionCommand] = React.useState<Command | null>(null);
const [confirmActionType, setConfirmActionType] = React.useState<'delete' | 'reset' | null>(null);
const [isConfirmActionPending, setIsConfirmActionPending] = React.useState(false);
const [openMenuCommand, setOpenMenuCommand] = React.useState<string | null>(null);
const {
selectedCommandName,
@@ -48,11 +48,6 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
} = useCommandsStore();
const { skills, loadSkills } = useSkillsStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
loadCommands();
loadSkills();
@@ -74,7 +69,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
}
}, [selectedCommandName, setSelectedCommand, skillNames]);
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const bgClass = 'bg-background';
const handleCreateNew = () => {
// Generate unique name
@@ -91,9 +86,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
setSelectedCommand(newName);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteCommand = async (command: Command) => {
@@ -162,13 +155,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
template: command.template,
agent: command.agent,
model: command.model,
subtask: command.subtask,
});
setSelectedCommand(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleOpenRenameDialog = (command: Command) => {
@@ -203,7 +193,6 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
template: renameDialogCommand.template,
agent: renameDialogCommand.agent,
model: renameDialogCommand.model,
subtask: renameDialogCommand.subtask,
});
if (success) {
@@ -227,18 +216,18 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Commands</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {commandOnlyItems.length}</span>
<Button
type="button"
<ButtonSmall
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="size-4" />
</Button>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
@@ -264,12 +253,12 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ 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<CommandsSidebarProps> = ({ 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<CommandsSidebarProps> = ({ onItemSelect }
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={closeConfirmActionDialog}
disabled={isConfirmActionPending}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}>
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
</ButtonLarge>
@@ -357,13 +345,12 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
}}
/>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={() => setRenameDialogCommand(null)}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleRenameCommand}>
Rename
</ButtonLarge>
@@ -382,6 +369,8 @@ interface CommandListItemProps {
onReset?: () => void;
onRename?: () => void;
onDuplicate: () => void;
isMenuOpen: boolean;
onMenuOpenChange: (open: boolean) => void;
}
const CommandListItem: React.FC<CommandListItemProps> = ({
@@ -392,13 +381,20 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
onReset,
onRename,
onDuplicate,
isMenuOpen,
onMenuOpenChange,
}) => {
const isMobile = isMobileDeviceViaCSS();
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
>
<div className="flex min-w-0 flex-1 items-center">
<button
@@ -424,15 +420,14 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
)}
</button>
<DropdownMenu>
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
<ButtonSmall
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</ButtonSmall>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && (
@@ -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<GitIdentityAuthType>('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<GitIdentityProfile, 'id'> & { 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 (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiUser3Line className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">Select a profile from the sidebar</p>
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
</div>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{importData ? 'Import Credential' : isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}
</h1>
<p className="typography-body text-muted-foreground mt-1">
{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'}
</p>
</div>
{}
{!isGlobalProfile && (
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Profile Information</h2>
<p className="typography-meta text-muted-foreground/80">
Basic profile settings and display name
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Display Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Work Profile, Personal, etc."
/>
<p className="typography-meta text-muted-foreground">
Friendly name to identify this profile (optional, defaults to user name)
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Color
</label>
<div className="flex gap-2 flex-wrap">
{PROFILE_COLORS.map((c) => (
<button
key={c.key}
onClick={() => setColor(c.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all',
color === c.key
? 'border-foreground scale-110'
: 'border-transparent hover:border-border'
)}
style={{ backgroundColor: c.cssVar }}
title={c.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon
</label>
<div className="flex gap-2 flex-wrap">
{PROFILE_ICONS.map((i) => {
const IconComponent = i.Icon;
return (
<button
key={i.key}
onClick={() => setIcon(i.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
icon === i.key
? 'border-primary bg-accent scale-110'
: 'border-border hover:border-primary/50'
)}
title={i.label}
>
<IconComponent
className="w-4 h-4"
style={{ color: currentColorValue }}
/>
</button>
);
})}
</div>
</div>
</div>
</div>
)}
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Git Configuration</h2>
<p className="typography-meta text-muted-foreground/80">
Git user settings that will be applied to repositories
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
User Name {!isGlobalProfile && <span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The name that will appear in Git commit messages.<br/>
This is the author name shown in git log and GitHub/GitLab interfaces.
</TooltipContent>
</Tooltip>
</label>
<Input
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="John Doe"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Git user.name configuration value
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
User Email {!isGlobalProfile && <span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The email address for Git commits.<br/>
This should match your email in GitHub/GitLab<br/>
to ensure proper attribution of commits.
</TooltipContent>
</Tooltip>
</label>
<Input
type="email"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
placeholder="john@example.com"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Git user.email configuration value
</p>
</div>
{/* Auth Type Selector */}
{!isGlobalProfile && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Authentication Type
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
SSH: Uses SSH key for authentication<br/>
Token: Uses personal access token from ~/.git-credentials
</TooltipContent>
</Tooltip>
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => 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'
)}
>
<RiLock2Line className="w-4 h-4" />
<span className="typography-ui-label">SSH Key</span>
</button>
<button
type="button"
onClick={() => 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'
)}
>
<RiKeyLine className="w-4 h-4" />
<span className="typography-ui-label">Token (HTTPS)</span>
</button>
</div>
</div>
)}
{/* SSH Key Path - only for SSH auth type */}
{authType === 'ssh' && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
SSH Key Path
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Path to SSH private key used for Git authentication.<br/>
This key will be used for SSH Git operations.<br/>
Common paths: ~/.ssh/id_rsa, ~/.ssh/id_ed25519
</TooltipContent>
</Tooltip>
</label>
<Input
value={sshKey}
onChange={(e) => setSshKey(e.target.value)}
placeholder="/Users/username/.ssh/id_rsa"
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Path to SSH private key for authentication (optional)
</p>
</div>
)}
{/* Host - only for Token auth type */}
{authType === 'token' && !isGlobalProfile && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Host {<span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The Git host this credential applies to.<br/>
Token will be read from ~/.git-credentials for this host.<br/>
Examples: github.com, gitlab.com
</TooltipContent>
</Tooltip>
</label>
<Input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="github.com"
required
/>
<p className="typography-meta text-muted-foreground">
Git host for token authentication (from ~/.git-credentials)
</p>
</div>
)}
{}
{!isGlobalProfile && (
<div className="flex justify-between border-t border-border/40 pt-4">
{!isNewProfile && (
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
className="gap-2 h-6 px-2 text-xs"
>
<RiDeleteBinLine className="h-3 w-3" />
Delete Profile
</Button>
)}
<div className={cn('flex gap-2', isNewProfile && 'ml-auto')}>
<Button
size="sm"
variant="default"
onClick={handleSave}
disabled={isSaving}
className="gap-2 h-6 px-2 text-xs"
>
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : 'Save Profile'}
</Button>
</div>
</div>
)}
</div>
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
if (!isDeleting) {
setIsDeleteDialogOpen(open);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete profile "{selectedProfile?.name || name || 'this profile'}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeleting}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ScrollableOverlay>
);
};
@@ -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<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
branch: RiGitBranchLine,
briefcase: RiBriefcaseLine,
house: RiHomeLine,
graduation: RiGraduationCapLine,
code: RiCodeLine,
heart: RiHeartLine,
};
const COLOR_MAP: Record<string, string> = {
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<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
const [deleteDialogProfile, setDeleteDialogProfile] = React.useState<GitIdentityProfile | null>(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 (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {profiles.length}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateProfile}
aria-label="Create new profile"
>
<RiAddLine className="size-4" />
</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{}
{globalIdentity && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
System Default
</div>
<ProfileListItem
profile={globalIdentity}
isSelected={selectedProfileId === 'global'}
isDefault={defaultGitIdentityId === 'global'}
onSelect={() => {
setSelectedProfile('global');
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onToggleDefault={() => handleToggleDefault('global')}
onDelete={undefined}
isReadOnly
/>
</>
)}
{}
{profiles.length > 0 && (
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Custom Profiles
</div>
)}
{profiles.length === 0 && !globalIdentity && unimportedCredentials.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiGitBranchLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No profiles configured</p>
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
</div>
) : (
<>
{profiles.map((profile) => (
<ProfileListItem
key={profile.id}
profile={profile}
isSelected={selectedProfileId === profile.id}
isDefault={defaultGitIdentityId === profile.id}
onSelect={() => {
setSelectedProfile(profile.id);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onToggleDefault={() => handleToggleDefault(profile.id)}
onDelete={() => handleDeleteProfile(profile)}
/>
))}
</>
)}
{/* Discovered Credentials Section */}
{unimportedCredentials.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Discovered Credentials
</div>
<p className="px-2 pb-2 typography-micro text-muted-foreground/60">
Found in ~/.git-credentials
</p>
{unimportedCredentials.map((cred) => (
<DiscoveredCredentialItem
key={`${cred.host}-${cred.username}`}
credential={cred}
onImport={() => handleImportCredential(cred)}
/>
))}
</>
)}
</ScrollableOverlay>
<Dialog
open={deleteDialogProfile !== null}
onOpenChange={(open) => {
if (!open && !isDeletePending) {
setDeleteDialogProfile(null);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete profile "{deleteDialogProfile?.name}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDeleteProfile()} disabled={isDeletePending}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
interface ProfileListItemProps {
profile: GitIdentityProfile;
isSelected: boolean;
isDefault?: boolean;
onSelect: () => void;
onToggleDefault?: () => void | Promise<void>;
onDelete?: () => void;
isReadOnly?: boolean;
}
const ProfileListItem: React.FC<ProfileListItemProps> = ({
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 (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
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"
tabIndex={0}
>
<div className="flex items-center gap-1.5">
<IconComponent
className="w-4 h-4 flex-shrink-0"
style={{ color: iconColor }}
/>
<span className="typography-ui-label font-normal truncate text-foreground">
{profile.name}
</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{authType}
</span>
{isDefault && (
<span className="typography-micro text-primary bg-primary/12 px-1 rounded flex-shrink-0 leading-none pb-px border border-primary/25">
default
</span>
)}
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{authType === 'token' && profile.host ? profile.host : profile.userEmail}
</div>
</button>
{(onToggleDefault || (!isReadOnly && onDelete)) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
aria-label="Profile actions"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
{onToggleDefault && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
void onToggleDefault();
}}
>
{isDefault ? 'Unset default' : 'Set as default'}
</DropdownMenuItem>
)}
{!isReadOnly && onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
};
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<DiscoveredCredentialItemProps> = ({
credential,
onImport,
}) => {
const displayName = getCredentialDisplayName(credential.host);
const isRepoSpecific = credential.host.includes('/');
return (
<div className="group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 hover:bg-interactive-hover">
<div className="flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 flex-1 flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label font-normal truncate text-foreground">
{displayName}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{isRepoSpecific ? credential.host : credential.username}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onImport}
className="h-6 px-2 text-xs gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiDownloadLine className="h-3 w-3" />
Import
</Button>
</div>
</div>
);
};
@@ -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<GitIdentityEditorDialogProps> = ({
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<GitIdentityAuthType>('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<GitIdentityProfile, 'id'> & { 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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{isGlobalProfile
? 'System-wide Git identity (read-only)'
: isNewProfile
? 'Create a new Git identity profile'
: 'Edit identity profile settings'}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Profile Display */}
{!isGlobalProfile && (
<div className="space-y-3">
<div>
<label className="typography-ui-label text-foreground block mb-1.5">Profile Name</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Work Profile, Personal, etc."
className="h-8"
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Color</span>
<div className="flex gap-1.5">
{PROFILE_COLORS.map((c) => (
<button
key={c.key}
type="button"
onClick={() => 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}
/>
))}
</div>
</div>
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Icon</span>
<div className="flex gap-1.5">
{PROFILE_ICONS.map((i) => {
const IconComponent = i.Icon;
return (
<button
key={i.key}
type="button"
onClick={() => 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}
>
<IconComponent
className="w-3.5 h-3.5"
style={{ color: icon === i.key ? currentColorValue : 'var(--surface-muted-foreground)' }}
/>
</button>
);
})}
</div>
</div>
</div>
)}
{/* Separator */}
{!isGlobalProfile && <div className="border-t border-border/40" />}
{/* Git Author */}
<div className="space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">User Name</label>
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The name that will appear in Git commit messages.
</TooltipContent>
</Tooltip>
</div>
<Input
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="John Doe"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
className="h-8"
/>
</div>
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">Email Address</label>
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Should match your email in GitHub/GitLab for proper attribution.
</TooltipContent>
</Tooltip>
</div>
<Input
type="email"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
placeholder="john@example.com"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
className="h-8"
/>
</div>
</div>
{/* Authentication */}
{!isGlobalProfile && (
<>
<div className="border-t border-border/40" />
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Auth Method</span>
<div className="flex items-center gap-1">
<ButtonSmall
type="button"
variant="outline"
onClick={() => 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'
)}
>
<RiLock2Line className="w-3.5 h-3.5 mr-1" /> SSH
</ButtonSmall>
<ButtonSmall
type="button"
variant="outline"
onClick={() => 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'
)}
>
<RiKeyLine className="w-3.5 h-3.5 mr-1" /> Token
</ButtonSmall>
</div>
</div>
{authType === 'ssh' && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">SSH Key Path</label>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Optional path to private key. e.g. ~/.ssh/id_ed25519
</TooltipContent>
</Tooltip>
</div>
<Input
value={sshKey}
onChange={(e) => setSshKey(e.target.value)}
placeholder="~/.ssh/id_ed25519"
className="h-8 font-mono text-xs"
/>
</div>
)}
{authType === 'token' && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">Host</label>
<span className="text-[var(--status-error)] text-xs">*</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Token will be read from ~/.git-credentials for this host.
</TooltipContent>
</Tooltip>
</div>
<Input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="github.com"
required
className="h-8 font-mono text-xs"
/>
</div>
)}
</div>
</>
)}
</div>
<DialogFooter className="gap-2">
{!isGlobalProfile && !isNewProfile && (
<Button
variant="outline"
size="sm"
onClick={() => 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"
>
<RiDeleteBinLine className="w-3.5 h-3.5 mr-1" /> Delete
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} className="text-foreground hover:bg-interactive-hover hover:text-foreground">
{isGlobalProfile ? 'Close' : 'Cancel'}
</Button>
{!isGlobalProfile && (
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : isNewProfile ? 'Create' : 'Save'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(o) => { if (!isDeleting) setIsDeleteDialogOpen(o); }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedProfile?.name || name}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
Cancel
</Button>
<Button size="sm" onClick={() => void handleConfirmDelete()} disabled={isDeleting} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -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<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
branch: RiGitBranchLine,
briefcase: RiBriefcaseLine,
house: RiHomeLine,
graduation: RiGraduationCapLine,
code: RiCodeLine,
heart: RiHeartLine,
};
const COLOR_MAP: Record<string, string> = {
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<string | null>(null);
const [editorImportData, setEditorImportData] = React.useState<{ host: string; username: string } | null>(null);
const [deleteDialogProfile, setDeleteDialogProfile] = React.useState<GitIdentityProfile | null>(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 (
<>
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
<GitHubSettings />
{/* Identities Section */}
<div className="border-t border-border/40 pt-6">
<div className="mb-3 px-1 flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Identities</h3>
</div>
<ButtonSmall variant="outline" onClick={() => openEditor('new')}>
<RiAddLine className="w-3.5 h-3.5 mr-1" /> New
</ButtonSmall>
</div>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
{/* Global identity */}
{globalIdentity && (
<IdentityRow
profile={globalIdentity}
isDefault={defaultGitIdentityId === 'global'}
onEdit={() => openEditor('global')}
onToggleDefault={() => handleToggleDefault('global')}
isReadOnly
hasBorder={profiles.length > 0 || unimportedCredentials.length > 0}
/>
)}
{/* Custom profiles */}
{profiles.map((profile, i) => (
<IdentityRow
key={profile.id}
profile={profile}
isDefault={defaultGitIdentityId === profile.id}
onEdit={() => 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 && (
<div className="py-8 px-4 text-center text-muted-foreground">
<RiShieldKeyholeLine className="mx-auto mb-2 h-8 w-8 opacity-40" />
<p className="typography-ui-label">No identities configured</p>
<p className="typography-meta mt-1 opacity-75">Create one to manage Git author settings per project</p>
</div>
)}
{/* Discovered credentials */}
{unimportedCredentials.length > 0 && (
<>
<div className="px-4 py-2 border-t border-[var(--surface-subtle)]">
<span className="typography-micro text-muted-foreground">
Found in ~/.git-credentials
</span>
</div>
{unimportedCredentials.map((cred, i) => (
<DiscoveredRow
key={`${cred.host}-${cred.username}`}
credential={cred}
onImport={() => openEditor('new', { host: cred.host, username: cred.username })}
hasBorder={i < unimportedCredentials.length - 1}
/>
))}
</>
)}
</div>
</div>
<GitSettings />
</div>
</ScrollableOverlay>
{/* Editor dialog */}
<GitIdentityEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
profileId={editorProfileId}
importData={editorImportData}
/>
{/* Delete confirmation */}
<Dialog
open={deleteDialogProfile !== null}
onOpenChange={(o) => { if (!isDeletePending) { if (!o) setDeleteDialogProfile(null); } }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{deleteDialogProfile?.name}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
Cancel
</Button>
<ButtonLarge onClick={() => void handleConfirmDelete()} disabled={isDeletePending} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
Delete
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
// --- Identity row ---
interface IdentityRowProps {
profile: GitIdentityProfile;
isDefault: boolean;
onEdit: () => void;
onToggleDefault: () => void;
onDelete?: () => void;
isReadOnly?: boolean;
hasBorder?: boolean;
}
const IdentityRow: React.FC<IdentityRowProps> = ({
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 (
<div
className={cn(
'group flex items-center justify-between gap-3 px-4 py-2.5 transition-colors hover:bg-[var(--interactive-hover)]/30 cursor-pointer',
hasBorder && 'border-b border-[var(--surface-subtle)]'
)}
onClick={onEdit}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onEdit(); }}
>
<div className="flex items-center gap-3 min-w-0">
<IconComponent className="w-4 h-4 shrink-0" style={{ color: iconColor }} />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground truncate">{profile.name}</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{authType}
</span>
{isDefault && (
<span className="typography-micro text-primary bg-primary/12 px-1 rounded flex-shrink-0 leading-none pb-px border border-primary/25">
default
</span>
)}
{isReadOnly && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
system
</span>
)}
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{authType === 'token' && profile.host ? profile.host : profile.userEmail}
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onToggleDefault(); }}>
{isDefault ? 'Unset default' : 'Set as default'}
</DropdownMenuItem>
{!isReadOnly && onDelete && (
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
// --- Discovered credential row ---
interface DiscoveredRowProps {
credential: DiscoveredGitCredential;
onImport: () => void;
hasBorder?: boolean;
}
const DiscoveredRow: React.FC<DiscoveredRowProps> = ({ 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 (
<div
className={cn(
'flex items-center justify-between gap-3 px-4 py-2.5 transition-colors hover:bg-[var(--interactive-hover)]/30',
hasBorder && 'border-b border-[var(--surface-subtle)]'
)}
>
<div className="min-w-0">
<span className="typography-ui-label text-foreground truncate block">{displayName}</span>
<span className="typography-micro text-muted-foreground/60 truncate block leading-tight">
{isRepoSpecific ? credential.host : credential.username}
</span>
</div>
<ButtonSmall variant="ghost" onClick={onImport} className="gap-1 shrink-0">
<RiDownloadLine className="h-3 w-3" />
Import
</ButtonSmall>
</div>
);
};
@@ -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<CommandTextareaProps> = ({ value, onChange }) =>
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="typography-micro text-muted-foreground">
One argument per line. Blank lines are ignored.
</p>
<Button
<div className="flex items-center justify-end gap-2">
<ButtonSmall
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 typography-micro text-muted-foreground"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={handlePasteFromClipboard}
type="button"
title="Paste full command from clipboard and auto-split"
>
<RiClipboardLine className="h-3 w-3" />
Paste command
</Button>
</ButtonSmall>
</div>
<Textarea
@@ -255,17 +252,17 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
<span className="typography-micro text-muted-foreground w-32 shrink-0">Key</span>
<span className="typography-micro text-muted-foreground">Value</span>
</div>
<Button
<ButtonSmall
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 typography-micro text-muted-foreground"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={handlePasteDotEnv}
type="button"
title="Paste KEY=VALUE lines from clipboard"
>
<RiClipboardLine className="h-3 w-3" />
Paste .env
</Button>
</ButtonSmall>
</div>
{/* Rows */}
@@ -302,28 +299,27 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
</button>
</div>
{/* Remove */}
<Button
size="icon"
<ButtonSmall
variant="ghost"
className="h-8 w-7 shrink-0 text-muted-foreground hover:text-destructive"
className="h-7 w-7 px-0 shrink-0 text-muted-foreground hover:text-[var(--status-error)]"
onClick={() => removeRow(idx)}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</Button>
</ButtonSmall>
</div>
))}
</div>
<Button
<ButtonSmall
variant="outline"
size="sm"
className="gap-1.5 h-7 typography-meta"
size="xs"
className="!font-normal gap-1.5"
onClick={addRow}
type="button"
>
<RiAddLine className="h-3.5 w-3.5" />
Add variable
</Button>
</ButtonSmall>
{hasSensitiveValues && (
<p className="typography-micro text-muted-foreground/60">
@@ -345,16 +341,14 @@ const STATUS_LABEL: Record<string, string> = {
};
const StatusBadge: React.FC<{ status: string | undefined; enabled: boolean }> = ({ status, enabled }) => {
if (!enabled) {
return <span className="typography-micro text-muted-foreground/50">Disabled</span>;
}
if (!enabled) return null;
if (!status) return null;
const colorMap: Record<string, string> = {
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 (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-2xl space-y-5 p-6">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* ── Header card: name + status + enabled + connect ── */}
<div className="rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3 space-y-3">
{/* Row 1: name + connect button */}
<div className="flex items-center justify-between gap-3 min-w-0">
<div className="min-w-0">
{isNewServer ? (
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
placeholder="my-mcp-server"
className="font-mono text-base h-8 w-64"
autoFocus
/>
) : (
<h1 className="typography-ui-header font-semibold truncate">{selectedMcpName}</h1>
)}
{isNewServer && (
<p className="typography-micro text-muted-foreground mt-0.5">
Lowercase, numbers, hyphens and underscores only
</p>
{/* Header */}
<div className="mb-4">
<div className="min-w-0">
{isNewServer ? (
<h2 className="typography-ui-header font-semibold text-foreground truncate">New MCP Server</h2>
) : (
<div className="flex items-center gap-2 min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">{selectedMcpName}</h2>
<StatusBadge status={runtimeStatus?.status} enabled={enabled} />
</div>
)}
<div className="flex items-center gap-2 mt-0.5">
<p className="typography-meta text-muted-foreground truncate">
{isNewServer ? 'Configure a new MCP server' : `${mcpType === 'local' ? 'Local · stdio' : 'Remote · SSE'} transport`}
</p>
{!isNewServer && (
<ButtonSmall
variant={isConnected ? 'outline' : 'default'}
size="xs"
className="!font-normal"
onClick={handleToggleConnect}
disabled={isConnecting || !enabled}
>
{isConnecting ? 'Working...' : isConnected ? 'Disconnect' : 'Connect'}
</ButtonSmall>
)}
</div>
{isNewServer && (
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
<SelectTrigger className="!h-8 w-auto gap-1.5">
{draftScope === 'user' ? (
<RiUser3Line className="h-4 w-4" />
) : (
<RiFolderLine className="h-4 w-4" />
)}
<span className="capitalize">{draftScope}</span>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<RiUser3Line className="h-4 w-4" />
<span>User</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
</div>
</SelectItem>
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<RiFolderLine className="h-4 w-4" />
<span>Project</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
)}
{!isNewServer && (
<Button
size="sm"
variant={isConnected ? 'outline' : 'default'}
onClick={handleToggleConnect}
disabled={isConnecting || !enabled}
className="h-7 shrink-0"
>
{isConnecting ? 'Working…' : isConnected ? 'Disconnect' : 'Connect'}
</Button>
)}
</div>
{/* Row 2: status + type badge + enabled toggle */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<StatusBadge status={runtimeStatus?.status} enabled={enabled} />
<span className="typography-micro text-muted-foreground/40">·</span>
<span className="typography-micro text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border/50">
{mcpType === 'local' ? 'stdio' : 'remote'}
</span>
</div>
{/* Enabled toggle */}
<div className="flex items-center gap-2">
<span className={cn('typography-micro', enabled ? 'text-foreground' : 'text-muted-foreground/60')}>
{enabled ? 'Enabled' : 'Disabled'}
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={() => 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',
)}
>
<span className={cn(
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform',
enabled ? 'translate-x-4' : 'translate-x-0',
)} />
</button>
</div>
</div>
{/* Row 3: type selector — always visible so user can switch type */}
<div className="flex gap-1 pt-1 border-t border-[var(--interactive-border)]">
<ButtonSmall
variant={mcpType === 'local' ? 'default' : 'outline'}
onClick={() => setMcpType('local')}
className={cn(mcpType !== 'local' && 'text-foreground')}
>
Local · stdio
</ButtonSmall>
<ButtonSmall
variant={mcpType === 'remote' ? 'default' : 'outline'}
onClick={() => setMcpType('remote')}
className={cn(mcpType !== 'remote' && 'text-foreground')}
>
Remote · SSE
</ButtonSmall>
</div>
</div>
{/* ── Connection ── */}
<div className="space-y-2">
{mcpType === 'local' ? (
<>
<label className="typography-ui-label font-medium text-foreground">
Command
</label>
{/* Server Identity */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">Server</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewServer && (
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Server Name</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
placeholder="my-mcp-server"
className="h-7 w-48 font-mono px-2"
autoFocus
/>
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
<SelectTrigger className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? 'User scope' : 'Project scope'}>
{draftScope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user">
<div className="flex items-center gap-2">
<RiUser3Line className="h-3.5 w-3.5" />
<span>User</span>
</div>
</SelectItem>
<SelectItem value="project">
<div className="flex items-center gap-2">
<RiFolderLine className="h-3.5 w-3.5" />
<span>Project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={enabled}
onClick={() => setEnabled(!enabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setEnabled(!enabled);
}
}}
>
<Checkbox
checked={enabled}
onChange={setEnabled}
ariaLabel="Enable server"
/>
<span className="typography-ui-label text-foreground">Enable Server</span>
</div>
<div className="pb-1.5 pt-0.5">
<div className="flex min-w-0 flex-col gap-1.5">
<span className="typography-ui-label text-foreground">Transport Mode</span>
<div className="flex flex-wrap items-center gap-1">
<ButtonSmall
variant="outline"
size="xs"
onClick={() => 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
</ButtonSmall>
<ButtonSmall
variant="outline"
size="xs"
onClick={() => 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
</ButtonSmall>
</div>
</div>
</div>
</section>
</div>
{/* Connection */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{mcpType === 'local' ? 'Command' : 'Server URL'}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
{mcpType === 'local' ? (
<CommandTextarea value={command} onChange={setCommand} />
</>
) : (
<>
<label className="typography-ui-label font-medium text-foreground">
Server URL
</label>
) : (
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://mcp.example.com/mcp"
className="font-mono typography-meta"
/>
<p className="typography-micro text-muted-foreground">
SSE endpoint URL of the remote MCP server
</p>
</>
)}
)}
</section>
</div>
{/* ── Environment Variables ── */}
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<label className="typography-ui-label font-medium text-foreground">
{/* Environment Variables */}
<div className="mb-2">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Environment Variables
{envEntries.length > 0 && (
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
({envEntries.length})
</span>
)}
</label>
</h3>
</div>
<EnvEditor value={envEntries} onChange={setEnvEntries} />
<section className="px-2 pb-2 pt-0">
<EnvEditor value={envEntries} onChange={setEnvEntries} />
</section>
</div>
{/* ── Actions ── */}
<div className="flex items-center justify-between border-t border-[var(--interactive-border)] pt-4 gap-4">
{!isNewServer ? (
<Button
size="sm"
variant="ghost"
onClick={() => setShowDeleteConfirm(true)}
className="h-7 gap-1.5 typography-meta text-destructive hover:text-destructive hover:bg-destructive/10"
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
Delete
</Button>
) : <div />}
<Button
size="sm"
{/* Actions */}
<div className="flex items-center gap-2 px-2 py-1">
<ButtonSmall
onClick={handleSave}
disabled={isSaving || (!isDirty && !isNewServer)}
className="h-7 gap-1.5 typography-meta"
size="xs"
className="!font-normal"
>
<RiSaveLine className="h-3.5 w-3.5" />
{isSaving ? 'Saving…' : isNewServer ? 'Create' : 'Save changes'}
</Button>
{isSaving ? 'Saving...' : isNewServer ? 'Create' : 'Save Changes'}
</ButtonSmall>
{!isNewServer && (
<ButtonSmall
variant="ghost"
size="xs"
className="!font-normal text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setShowDeleteConfirm(true)}
>
Delete
</ButtonSmall>
)}
</div>
</div>
@@ -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<StatusTone, string> = {
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<McpSidebarProps> = ({ 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<McpSidebarProps> = ({ onItemSelect }) => {
const [deleteTarget, setDeleteTarget] = React.useState<McpServerConfig | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
const [openMenuMcp, setOpenMenuMcp] = React.useState<string | null>(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<McpSidebarProps> = ({ onItemSelect }) => {
return (
<div className={cn('flex h-full flex-col', bgClass)}>
{/* Header */}
<div className="border-b px-3 py-3">
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">MCP Servers</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">
{mcpServers.length} server{mcpServers.length !== 1 ? 's' : ''}
Total {mcpServers.length}
</span>
<Button
type="button"
<ButtonSmall
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
title="Add MCP server"
>
<RiAddLine className="size-4" />
</Button>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
@@ -141,74 +150,147 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
<p className="typography-meta mt-1 opacity-75">Use the + button above to add one</p>
</div>
) : (
mcpServers.map((server) => {
const runtimeStatus = mcpStatus[server.name];
const tone = statusToneFromMcp(runtimeStatus?.status);
const isSelected = selectedMcpName === server.name;
<>
{projectServers.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Project Servers
</div>
{projectServers.map((server) => {
const runtimeStatus = mcpStatus[server.name];
const tone = statusToneFromMcp(runtimeStatus?.status);
const isSelected = selectedMcpName === server.name;
const isMobile = isMobileDeviceViaCSS();
return (
<div
key={server.name}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)}
>
<button
onClick={() => {
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"
>
<div className="flex items-center gap-2">
<StatusDot tone={tone} enabled={server.enabled} />
<span className="typography-ui-label font-normal truncate text-foreground">
{server.name}
</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{server.type}
</span>
{!server.enabled && (
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">
off
</span>
)}
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
{server.type === 'local'
? (server as { command?: string[] }).command?.join(' ') ?? ''
: (server as { url?: string }).url ?? ''}
</div>
</button>
return (
<div
key={server.name}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
setOpenMenuMcp(server.name);
} : undefined}
>
<button
onClick={() => {
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"
>
<div className="flex items-center gap-2">
<StatusDot tone={tone} enabled={server.enabled} />
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{server.type}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
{server.type === 'local'
? (server as { command?: string[] }).command?.join(' ') ?? ''
: (server as { url?: string }).url ?? ''}
</div>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
<DropdownMenuTrigger asChild>
<ButtonSmall variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<RiMore2Line className="h-3.5 w-3.5" />
</ButtonSmall>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(server);
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</>
)}
{userServers.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
User Servers
</div>
{userServers.map((server) => {
const runtimeStatus = mcpStatus[server.name];
const tone = statusToneFromMcp(runtimeStatus?.status);
const isSelected = selectedMcpName === server.name;
const isMobile = isMobileDeviceViaCSS();
return (
<div
key={server.name}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
setOpenMenuMcp(server.name);
} : undefined}
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(server);
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})
<button
onClick={() => {
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"
>
<div className="flex items-center gap-2">
<StatusDot tone={tone} enabled={server.enabled} />
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{server.type}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
{server.type === 'local'
? (server as { command?: string[] }).command?.join(' ') ?? ''
: (server as { url?: string }).url ?? ''}
</div>
</button>
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
<DropdownMenuTrigger asChild>
<ButtonSmall variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<RiMore2Line className="h-3.5 w-3.5" />
</ButtonSmall>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(server);
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</>
)}
</>
)}
</ScrollableOverlay>
@@ -226,14 +308,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting…' : 'Delete'}
</ButtonLarge>
@@ -245,4 +326,4 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
};
// Re-export for easy sidebar icon usage
export { RiServerLine as McpIcon };
export { McpIcon } from '@/components/icons/McpIcon';
@@ -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 && (
<button
onClick={() => 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"
>
<RiDownloadLine className="h-3.5 w-3.5" />
Update
@@ -76,7 +77,7 @@ export const AboutSettings: React.FC = () => {
</div>
{updateStore.error && (
<p className="typography-micro text-destructive truncate">{updateStore.error}</p>
<p className="typography-micro text-[var(--status-error)] truncate">{updateStore.error}</p>
)}
{/* Links row */}
@@ -129,99 +130,83 @@ export const AboutSettings: React.FC = () => {
}
// Desktop layout (unchanged)
// Desktop layout (redesigned)
return (
<div className="w-full space-y-6">
<div className="space-y-1">
<div className="mb-8">
<div className="mb-3 px-1">
<h3 className="typography-ui-header font-semibold text-foreground">
About OpenChamber
</h3>
</div>
{/* Version and Update */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="typography-ui-label text-muted-foreground">Version</div>
<div className="typography-ui-header font-mono">{currentVersion}</div>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-4 py-3 border-b border-[var(--surface-subtle)]">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Version</span>
<span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
</div>
<div className="flex items-center gap-3">
{updateStore.checking && (
<div className="flex items-center gap-2 text-muted-foreground">
<RiLoaderLine className="h-4 w-4 animate-spin" />
<span className="typography-meta">Checking...</span>
</div>
)}
{updateStore.checking && (
<div className="flex items-center gap-2 text-muted-foreground">
<RiLoaderLine className="h-4 w-4 animate-spin" />
<span className="typography-meta">Checking...</span>
</div>
)}
{!updateStore.checking && updateStore.available && (
<ButtonSmall
variant="default"
onClick={() => setUpdateDialogOpen(true)}
>
<RiDownloadLine className="h-4 w-4 mr-1" />
Update to {updateStore.info?.version}
</ButtonSmall>
)}
{!updateStore.checking && updateStore.available && (
<button
onClick={() => 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 && (
<span className="typography-meta text-muted-foreground">Up to date</span>
)}
<ButtonSmall
variant="outline"
onClick={() => updateStore.checkForUpdates()}
disabled={updateStore.checking}
>
<RiDownloadLine className="h-4 w-4" />
Update to {updateStore.info?.version}
</button>
)}
{!updateStore.checking && !updateStore.available && !updateStore.error && (
<span className="typography-meta text-muted-foreground">Up to date</span>
)}
Check for updates
</ButtonSmall>
</div>
</div>
{updateStore.error && (
<p className="typography-meta text-destructive">{updateStore.error}</p>
<div className="px-3 py-2 border-b border-[var(--surface-subtle)]">
<p className="typography-meta text-[var(--status-error)]">{updateStore.error}</p>
</div>
)}
<button
onClick={() => 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
</button>
<div className="flex items-center gap-4 px-4 py-4">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground typography-meta transition-colors"
>
<RiGithubFill className="h-4 w-4" />
<span>GitHub</span>
</a>
<a
href="https://x.com/btriapitsyn"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground typography-meta transition-colors"
>
<RiTwitterXFill className="h-4 w-4" />
<span>@btriapitsyn</span>
</a>
</div>
</div>
{/* Links */}
{/* Links */}
<div className="flex items-center gap-4">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
>
<RiGithubFill className="h-4 w-4" />
<span>GitHub</span>
</a>
<a
href="https://x.com/btriapitsyn"
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
>
<RiTwitterXFill className="h-4 w-4" />
<span>@btriapitsyn</span>
</a>
</div>
{/* Update Dialog */}
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
@@ -8,20 +8,17 @@ import { Checkbox } from '@/components/ui/checkbox';
import { updateDesktopSettings } from '@/lib/persistence';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getModifierLabel } from '@/lib/utils';
import { getModifierLabel, cn } from '@/lib/utils';
interface ZenModel {
id: string;
owned_by?: string;
}
const FALLBACK_PROVIDER_ID = 'opencode';
const FALLBACK_MODEL_ID = 'big-pickle';
const getDisplayModel = (
storedModel: string | undefined,
providers: Array<{ id: string; models: Array<{ id: string }> }>
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<string | undefined>();
@@ -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<HTMLInputElement>) => {
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 (
<div className="space-y-4">
<div className="space-y-1">
<div className="mb-6">
<div className="mb-0.5 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Session Defaults</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Configure default behaviors for new sessions.
</TooltipContent>
</Tooltip>
<h3 className="typography-ui-header font-medium text-foreground">Session Defaults</h3>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-1.5">
<label className="typography-ui-label text-muted-foreground">Default model</label>
<ModelSelector
providerId={parsedModel.providerId}
modelId={parsedModel.modelId}
onChange={handleModelChange}
/>
</div>
{supportsVariants && (
<div className="flex flex-col gap-1.5">
<label className="typography-ui-label text-muted-foreground">Default thinking</label>
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange}>
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground">
<SelectValue placeholder="Thinking" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_VARIANT_VALUE} className="pr-2 [&>span:first-child]:hidden">Default</SelectItem>
{availableVariants.map((variant) => (
<SelectItem key={variant} value={variant} className="pr-2 [&>span:first-child]:hidden">
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex flex-col gap-1.5">
<label className="typography-ui-label text-muted-foreground">Default agent</label>
<AgentSelector
agentName={defaultAgent || ''}
onChange={handleAgentChange}
/>
</div>
</div>
{(parsedModel.providerId || defaultAgent) && (
<div className="typography-meta text-muted-foreground">
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
New sessions will start with:{' '}
{parsedModel.providerId && (
{parsedModel.providerId ? (
<span className="text-foreground">
{parsedModel.providerId}/{parsedModel.modelId}
{supportsVariants ? ` (${defaultVariant ?? 'default'})` : ''}
</span>
) : (
<span className="text-foreground">opencode agent default</span>
)}
{defaultAgent && (
<>
{' / '}
<span className="text-foreground">{defaultAgent}</span>
</>
)}
{parsedModel.providerId && defaultAgent && ' / '}
{defaultAgent && <span className="text-foreground">{defaultAgent}</span>}
</div>
)}
<div className={cn("flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8")}>
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Model</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector
providerId={parsedModel.providerId}
modelId={parsedModel.modelId}
onChange={handleModelChange}
/>
</div>
</div>
{!isVSCode && (
<div className="pt-2">
<label className="flex items-center gap-2 cursor-pointer">
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Thinking</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange} disabled={!supportsVariants}>
<SelectTrigger className="w-fit min-w-[120px]">
<SelectValue placeholder="Thinking" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_VARIANT_VALUE}>Default</SelectItem>
{availableVariants.map((variant) => (
<SelectItem key={variant} value={variant}>
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Agent</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<AgentSelector
agentName={defaultAgent || ''}
onChange={handleAgentChange}
/>
</div>
</div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<div className="flex items-center gap-2">
<span className="typography-ui-label text-foreground">Zen Model</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The free model used for lightweight internal tasks like commit message generation, PR descriptions, notification summarization, and TTS text summarization.
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex items-center gap-2 sm:w-fit">
{zenModelsLoading ? (
<span className="typography-meta text-muted-foreground">Loading models...</span>
) : zenModels.length > 0 ? (
<Select value={selectedZenModel} onValueChange={handleZenModelChange}>
<SelectTrigger className="w-fit min-w-[120px]">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{zenModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="typography-meta text-muted-foreground">No free models available</span>
)}
</div>
</div>
<div
className="group flex cursor-pointer items-center gap-2 py-1"
role="button"
tabIndex={0}
aria-pressed={showDeletionDialog}
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowDeletionDialog(!showDeletionDialog);
}
}}
>
<Checkbox
checked={showDeletionDialog}
onChange={setShowDeletionDialog}
ariaLabel="Show deletion dialog"
/>
<span className="typography-ui-label text-foreground">Show Deletion Dialog</span>
</div>
{!isVSCode && (
<div
className="group flex cursor-pointer items-center gap-2 py-1"
role="button"
tabIndex={0}
aria-pressed={settingsAutoCreateWorktree}
onClick={() => {
void handleAutoWorktreeChange(!settingsAutoCreateWorktree);
}}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
void handleAutoWorktreeChange(!settingsAutoCreateWorktree);
}
}}
>
<Checkbox
checked={settingsAutoCreateWorktree}
onChange={(checked) => handleAutoWorktreeChange({ target: { checked } } as React.ChangeEvent<HTMLInputElement>)}
onChange={(checked) => {
void handleAutoWorktreeChange(checked);
}}
ariaLabel="Always create worktree"
/>
<span className="typography-ui-label text-foreground">
Always create worktree for new sessions
</span>
</label>
<p className="typography-meta text-muted-foreground pl-5 mt-1">
{settingsAutoCreateWorktree
? `New session (Worktree): ${getModifierLabel()} + N • New session (Standard): Shift + ${getModifierLabel()} + N`
: `New session (Standard): ${getModifierLabel()} + N • New session (Worktree): Shift + ${getModifierLabel()} + N`}
</p>
</div>
)}
<div className="border-t border-border/40 pt-4 mt-4 space-y-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Zen Model</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The free model used for lightweight internal tasks like commit message generation, PR descriptions, notification summarization, and TTS text summarization.
</TooltipContent>
</Tooltip>
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">Always Create Worktree</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{settingsAutoCreateWorktree
? `New session (Worktree): ${getModifierLabel()}+N\nStandard: Shift+${getModifierLabel()}+N`
: `New session (Standard): ${getModifierLabel()}+N\nWorktree: Shift+${getModifierLabel()}+N`}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<p className="typography-meta text-muted-foreground">
Used for commit messages, PR descriptions, and text summarization.
</p>
</div>
)}
<div className="flex flex-col gap-1.5">
<label className="typography-ui-label text-muted-foreground">Model</label>
{zenModelsLoading ? (
<span className="typography-meta text-muted-foreground">Loading models...</span>
) : zenModels.length > 0 ? (
<Select value={selectedZenModel} onValueChange={handleZenModelChange}>
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{zenModels.map((model) => (
<SelectItem key={model.id} value={model.id} className="pr-2 [&>span:first-child]:hidden">
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="typography-meta text-muted-foreground">No free models available</span>
)}
</div>
</div>
</section>
</div>
);
};
@@ -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 (
<div className="space-y-6">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">GitHub</h3>
<p className="typography-meta text-muted-foreground">
Connect a GitHub account for in-app PR and issue workflows.
</p>
<div className="mb-8">
<div className="mb-3 px-1 flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">GitHub</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Connect a GitHub account for in-app PR and issue workflows.
</TooltipContent>
</Tooltip>
</div>
</div>
{connected ? (
<div className="flex items-center justify-between gap-4 rounded-lg border bg-background/50 px-4 py-3">
<div className="flex min-w-0 items-center gap-4">
{user?.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.login ? `${user.login} avatar` : 'GitHub avatar'}
className="h-14 w-14 shrink-0 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="h-14 w-14 shrink-0 rounded-full border border-border/60 bg-muted" />
)}
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
{connected ? (
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
<div className={cn("flex min-w-0 items-center gap-4", isMobile ? "w-full" : undefined)}>
{user?.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.login ? `${user.login} avatar` : 'GitHub avatar'}
className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]" />
)}
<div className="min-w-0">
<div className="typography-ui-header font-semibold text-foreground truncate">
{user?.name?.trim() || user?.login || 'GitHub'}
<div className="min-w-0 flex-1">
<div className="typography-ui-label text-foreground">
{user?.name?.trim() || user?.login || 'GitHub'}
</div>
<div className={cn("flex items-center gap-2 typography-meta text-muted-foreground mt-0.5", isMobile ? "flex-wrap" : "truncate")}>
<RiGithubFill className="h-3.5 w-3.5 shrink-0" />
<span className="font-mono">{user?.login || 'unknown'}</span>
{user?.email && <span className="opacity-50"></span>}
{user?.email && <span>{user.email}</span>}
</div>
{status?.scope && (
<div className="typography-micro text-muted-foreground/70 mt-0.5">Scopes: {status.scope}</div>
)}
</div>
{user?.email ? (
<div className="typography-body text-muted-foreground truncate">{user.email}</div>
) : null}
<div className="mt-1 flex items-center gap-2 typography-meta text-muted-foreground truncate">
<RiGithubFill className="h-4 w-4" />
<span className="font-mono">{user?.login || 'unknown'}</span>
</div>
{status?.scope ? (
<div className="typography-micro text-muted-foreground truncate">Scopes: {status.scope}</div>
) : null}
</div>
<ButtonSmall variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
Disconnect
</ButtonSmall>
</div>
) : (
<div className="flex items-center justify-between gap-4 px-4 py-4">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Not Connected</span>
</div>
<ButtonSmall variant="default" onClick={startConnect} disabled={isBusy}>
Connect GitHub
</ButtonSmall>
</div>
)}
{accounts.length > 1 && (
<div className="mt-2 border-t border-[var(--surface-subtle)] pt-2 px-2 pb-1">
<div className="typography-micro text-muted-foreground mb-2 px-1">Other Accounts</div>
<div className="space-y-1">
{accounts.map((account) => {
const accountUser = account.user;
const isCurrent = Boolean(account.current);
return (
<div
key={account.id}
className="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-subtle)] bg-[var(--surface-muted)] px-3 py-2"
>
<div className="flex min-w-0 items-center gap-3">
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'}
className="h-6 w-6 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
<RiGithubFill className="h-3 w-3 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex flex-col">
<span className="typography-ui-label text-foreground truncate">
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
</span>
{accountUser?.login && (
<span className="typography-micro text-muted-foreground truncate font-mono">
{accountUser.login}
</span>
)}
</div>
</div>
{isCurrent ? (
<span className="typography-micro text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">Active</span>
) : (
<ButtonSmall
variant="ghost"
onClick={() => activateAccount(account.id)}
disabled={isBusy}
>
Switch to
</ButtonSmall>
)}
</div>
);
})}
</div>
</div>
)}
<Button variant="outline" onClick={disconnect} disabled={isBusy}>
Disconnect
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-background/50 px-3 py-2">
<div className="typography-ui-label text-foreground">Not connected</div>
<Button onClick={startConnect} disabled={isBusy}>
Connect
</Button>
</div>
{connected && (
<div className="mt-2 px-2 pb-2">
<ButtonSmall
variant="outline"
onClick={startConnect}
disabled={isBusy}
className={cn(isMobile ? 'w-full' : undefined)}
>
Add Account
</ButtonSmall>
</div>
)}
{connected ? (
<div className="flex justify-end">
<Button variant="ghost" onClick={startConnect} disabled={isBusy}>
Add account
</Button>
</div>
) : null}
{accounts.length > 1 ? (
<div className="space-y-2 rounded-lg border bg-background/50 p-3">
<div className="typography-ui-label text-foreground">Accounts</div>
<div className="space-y-2">
{accounts.map((account) => {
const accountUser = account.user;
const isCurrent = Boolean(account.current);
return (
<div
key={account.id}
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-background/70 px-3 py-2"
>
<div className="flex min-w-0 items-center gap-3">
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'}
className="h-8 w-8 shrink-0 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted">
<RiGithubFill className="h-4 w-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
</div>
{accountUser?.login ? (
<div className="typography-micro text-muted-foreground truncate font-mono">
{accountUser.login}
</div>
) : null}
</div>
</div>
{isCurrent ? (
<span className="typography-micro text-primary">Active</span>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => activateAccount(account.id)}
disabled={isBusy}
>
Use
</Button>
)}
</div>
);
})}
</div>
</div>
) : null}
{flow ? (
<div className="space-y-3 rounded-lg border bg-background/50 p-3">
{flow && (
<div className="mt-4 rounded-lg bg-[var(--surface-elevated)]/70 p-4 border border-[var(--interactive-border)]">
<div className="space-y-1">
<div className="typography-ui-label text-foreground">Authorize OpenChamber</div>
<div className="typography-meta text-muted-foreground">
In GitHub, enter this code:
</div>
<h4 className="typography-ui-label text-foreground">Authorize OpenChamber</h4>
<p className="typography-meta text-muted-foreground">
In GitHub, enter the following code to authorize this device:
</p>
</div>
<div className="flex items-center justify-between gap-3">
<div className="font-mono text-lg tracking-widest text-foreground">{flow.userCode}</div>
<Button variant="outline" asChild>
<div className="flex items-center justify-between gap-3 mt-4">
<div className="font-mono text-xl tracking-widest text-foreground bg-[var(--surface-muted)] px-3 py-1.5 rounded-md border border-[var(--interactive-border)]">{flow.userCode}</div>
<Button size="sm" asChild>
<a
href={flow.verificationUriComplete || flow.verificationUri}
target="_blank"
@@ -389,19 +409,19 @@ export const GitHubSettings: React.FC = () => {
</a>
</Button>
</div>
<div className="typography-micro text-muted-foreground">
Waiting for approval (auto-refresh)
</div>
<div className="flex justify-end">
<Button variant="ghost" disabled={isBusy} onClick={() => {
<div className="mt-4 flex items-center justify-between">
<span className="typography-micro text-muted-foreground animate-pulse">
Waiting for approval (auto-refresh)
</span>
<ButtonSmall variant="ghost" disabled={isBusy} onClick={() => {
stopPolling();
setFlow(null);
}}>
Cancel
</Button>
</ButtonSmall>
</div>
</div>
) : null}
)}
</div>
);
};
@@ -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<HTMLInputElement>) => {
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 (
<div className="space-y-6">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Commit Messages</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Configure how commit messages are generated.
</TooltipContent>
</Tooltip>
</div>
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">Git Preferences</h3>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={settingsGitmojiEnabled}
onChange={(checked) => handleGitmojiChange({ target: { checked } } as React.ChangeEvent<HTMLInputElement>)}
/>
<span className="typography-ui-label text-foreground">Enable gitmoji picker</span>
</label>
<p className="typography-meta text-muted-foreground pl-5.5">
Adds a gitmoji selector to the Git commit message input.
</p>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={settingsGitmojiEnabled}
onClick={() => {
void handleGitmojiChange(!settingsGitmojiEnabled);
}}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
void handleGitmojiChange(!settingsGitmojiEnabled);
}
}}
>
<Checkbox
checked={settingsGitmojiEnabled}
onChange={(checked) => {
void handleGitmojiChange(checked);
}}
ariaLabel="Enable Gitmoji picker"
/>
<span className="typography-ui-label text-foreground">Enable Gitmoji Picker</span>
</div>
</div>
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">Files Overview</h3>
<p className="typography-meta text-muted-foreground">
Show gitignored files in the Files browser pane only.
</p>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={showGitignored}
onChange={setFilesViewShowGitignored}
/>
<span className="typography-ui-label text-foreground">Display gitignored files</span>
</label>
<p className="typography-meta text-muted-foreground pl-5.5">
Toggles gitignored files in the Files tree and search results.
</p>
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showGitignored}
onClick={() => setFilesViewShowGitignored(!showGitignored)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setFilesViewShowGitignored(!showGitignored);
}
}}
>
<Checkbox
checked={showGitignored}
onChange={setFilesViewShowGitignored}
ariaLabel="Display gitignored files"
/>
<span className="typography-ui-label text-foreground">Display Gitignored Files</span>
</div>
</div>
</section>
</div>
);
};
@@ -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 (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">Keyboard Shortcuts</h3>
<p className="typography-meta text-muted-foreground">
Capture a new key combo, save it, and the runtime/help/palette bindings update together.
</p>
<div className="mb-8">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">Keyboard Shortcuts</h3>
<ButtonSmall
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
resetAllShortcutOverrides();
setDraftByAction({});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}}
>
Reset All
</ButtonSmall>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Capture a new key combo, save it, and bindings will update immediately.
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-2">
{actions.map((action) => {
{(errorText || warningText || pendingOverwrite) && (
<div className="mb-2 space-y-2 px-1">
{pendingOverwrite && (
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<span className="typography-meta text-foreground">
This combo is already used by another shortcut. Overwrite and clear that other mapping?
</span>
<div className="flex gap-2 shrink-0">
<ButtonSmall type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>Overwrite</ButtonSmall>
<ButtonSmall type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</ButtonSmall>
</div>
</div>
)}
{errorText && (
<div className="rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] p-3 typography-meta text-foreground">
{errorText}
</div>
)}
{warningText && (
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 typography-meta text-foreground">
{warningText}
</div>
)}
</div>
)}
<section className="px-2 pb-2 pt-0 space-y-0.5">
{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 (
<div key={action.id} className="rounded-md border border-border/60 p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="typography-ui-label text-foreground">{action.label}</p>
{action.description && (
<p className="typography-meta text-muted-foreground">{action.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<Input
readOnly
value={capturingActionId === action.id ? 'Press keys...' : formatShortcutForDisplay(displayCombo)}
onFocus={() => {
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,
}));
<div key={action.id} className={cn("flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8", index > 0 && "border-t border-[var(--surface-subtle)]")}>
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{action.label}</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<Input
readOnly
value={capturingActionId === action.id ? 'Press keys...' : formatShortcutForDisplay(displayCombo)}
onFocus={() => {
setCapturingActionId(action.id);
setErrorText('');
}}
onBlur={() => {
if (capturingActionId === action.id) {
setCapturingActionId(null);
setPendingOverwrite(null);
setErrorText('');
}}
className="w-52"
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const next = draftByAction[action.id];
if (!next) {
setErrorText('Capture a shortcut first.');
return;
}
saveCombo(action.id, next);
}}
disabled={!hasDraft}
>
Save
</Button>
<Button type="button" variant="ghost" onClick={() => resetOne(action.id)}>
Reset
</Button>
</div>
}
}}
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"
/>
<ButtonSmall
type="button"
variant="secondary"
size="xs"
className="!font-normal"
onClick={() => {
const next = draftByAction[action.id];
if (!next) {
setErrorText('Capture a shortcut first.');
return;
}
saveCombo(action.id, next);
}}
disabled={!hasDraft}
>
Save
</ButtonSmall>
<ButtonSmall type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
Reset
</ButtonSmall>
</div>
</div>
);
})}
</div>
{pendingOverwrite && (
<div className="rounded-md border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
<p className="typography-meta" style={{ color: 'var(--surface-foreground)' }}>
This combo is already used by another shortcut. Overwrite and clear that other mapping?
</p>
<div className="mt-2 flex gap-2">
<Button type="button" size="sm" onClick={confirmOverwrite}>Overwrite</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</Button>
</div>
</div>
)}
{errorText && (
<div
className="rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)] p-2 typography-meta"
style={{ color: 'var(--surface-foreground)' }}
>
{errorText}
</div>
)}
{warningText && (
<div
className="rounded-md border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-2 typography-meta"
style={{ color: 'var(--surface-foreground)' }}
>
{warningText}
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => {
resetAllShortcutOverrides();
setDraftByAction({});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}}
>
Reset all shortcuts
</Button>
</div>
</section>
</div>
);
};
@@ -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 (
<div className="space-y-4">
<div className="space-y-1">
<div className="mb-8">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Message Memory</h3>
<h3 className="typography-ui-header font-medium text-foreground">Message Memory</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
How many messages to keep in view per session.<br />
Limit how many messages are loaded per session in memory.<br />
Older messages are available via "Load more". Background sessions are trimmed automatically.
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">Message limit</span>
<span className="typography-meta text-muted-foreground">Messages loaded per session</span>
</div>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="typography-meta text-muted-foreground/60">(default: {DEFAULT_MESSAGE_LIMIT})</span>
)}
{isMobile ? (
<MobileInput value={messageLimit} min={MIN_LIMIT} max={MAX_LIMIT} onChange={handleChange} />
) : (
<NumberInput
value={messageLimit}
onValueChange={handleChange}
min={MIN_LIMIT}
max={MAX_LIMIT}
step={10}
aria-label="Message limit"
/>
)}
</div>
<section className="px-2 pb-2 pt-0">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Message Limit</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<NumberInput
value={messageLimit}
onValueChange={handleChange}
min={MIN_LIMIT}
max={MAX_LIMIT}
step={10}
aria-label="Message limit"
className="w-20 tabular-nums"
/>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => 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"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
</div>
</section>
</div>
);
};
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<HTMLInputElement>) => {
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 (
<input
type="number"
inputMode="numeric"
value={draft}
onChange={handleChange}
onBlur={handleBlur}
aria-label="Message limit"
className="h-8 w-20 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"
/>
);
};
@@ -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 (
<div className="space-y-6">
<div className="space-y-1 pt-2">
<h3 className="typography-ui-header font-semibold text-foreground">
When to notify
</h3>
<p className="typography-ui text-muted-foreground">
Customize when notifications show up.
</p>
</div>
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Enable notifications
</span>
<p className="typography-micro text-muted-foreground">
Turns notifications on or off.
</p>
</div>
<Switch
checked={nativeNotificationsEnabled && canShowNotifications}
onCheckedChange={handleToggleChange}
className="data-[state=checked]:bg-status-info"
/>
</div>
{isBrowser && (
<p className="typography-micro text-muted-foreground">
Your browser may ask for permission the first time.
</p>
)}
{nativeNotificationsEnabled && canShowNotifications && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Notify while app is focused
</span>
<p className="typography-micro text-muted-foreground">
When off, only notify when you are not looking at OpenChamber.
</p>
</div>
<Switch
checked={notificationMode === 'always'}
onCheckedChange={(checked: boolean) => setNotificationMode(checked ? 'always' : 'hidden-only')}
className="data-[state=checked]:bg-status-info"
/>
</div>
)}
{nativeNotificationsEnabled && canShowNotifications && (
<div className="space-y-3 pt-2">
<div className="space-y-0.5">
<span className="typography-ui text-foreground font-medium">
Events
</span>
<p className="typography-micro text-muted-foreground">
Choose which events trigger notifications.
</p>
{/* --- Global Delivery Settings --- */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Notification Delivery
</h3>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">Completion</span>
<p className="typography-micro text-muted-foreground">Agent finished its task.</p>
</div>
<Switch
checked={notifyOnCompletion}
onCheckedChange={setNotifyOnCompletion}
className="data-[state=checked]:bg-status-info"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">Errors</span>
<p className="typography-micro text-muted-foreground">A tool call failed.</p>
</div>
<Switch
checked={notifyOnError}
onCheckedChange={setNotifyOnError}
className="data-[state=checked]:bg-status-info"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">Questions</span>
<p className="typography-micro text-muted-foreground">Agent is asking for input or permission.</p>
</div>
<Switch
checked={notifyOnQuestion}
onCheckedChange={setNotifyOnQuestion}
className="data-[state=checked]:bg-status-info"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">Subagents</span>
<p className="typography-micro text-muted-foreground">Also notify for child sessions started by the main one.</p>
</div>
<Switch
checked={notifyOnSubtasks}
onCheckedChange={(checked: boolean) => setNotifyOnSubtasks(checked)}
className="data-[state=checked]:bg-status-info"
/>
</div>
</div>
)}
{nativeNotificationsEnabled && canShowNotifications && (
<div className="space-y-4 pt-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Customize content
</h3>
<p className="typography-micro text-muted-foreground">
Use template variables: <code className="text-accent-foreground">{'{project_name}'}</code>{' '}
<code className="text-accent-foreground">{'{worktree}'}</code>{' '}
<code className="text-accent-foreground">{'{branch}'}</code>{' '}
<code className="text-accent-foreground">{'{session_name}'}</code>{' '}
<code className="text-accent-foreground">{'{agent_name}'}</code>{' '}
<code className="text-accent-foreground">{'{last_message}'}</code>
</p>
</div>
{(['completion', 'error', 'question', 'subtask'] as const).map((event) => (
<div key={event} className="space-y-2">
<span className="typography-ui text-foreground font-medium capitalize">{event}</span>
<div className="space-y-1.5">
<div>
<label className="typography-micro text-muted-foreground block mb-1">Title</label>
<input
type="text"
value={notificationTemplates[event].title}
onChange={(e) => updateTemplate(event, 'title', e.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-1.5 typography-ui text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-accent"
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].title}
/>
</div>
<div>
<label className="typography-micro text-muted-foreground block mb-1">Message</label>
<input
type="text"
value={notificationTemplates[event].message}
onChange={(e) => updateTemplate(event, 'message', e.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-1.5 typography-ui text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-accent"
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].message}
/>
</div>
</div>
</div>
))}
</div>
)}
{nativeNotificationsEnabled && canShowNotifications && (
<div className="space-y-3 pt-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Summarization
</h3>
<p className="typography-micro text-muted-foreground">
Summarize long messages in notifications using AI.
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Summarize last message
</span>
<p className="typography-micro text-muted-foreground">
Uses AI to shorten the {'{last_message}'} variable.
</p>
</div>
<Switch
checked={summarizeLastMessage}
onCheckedChange={setSummarizeLastMessage}
className="data-[state=checked]:bg-status-info"
/>
</div>
{summarizeLastMessage ? (
<>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui text-foreground">
Summary threshold
</label>
<span className="typography-micro text-muted-foreground tabular-nums">{summaryThreshold} chars</span>
</div>
<p className="typography-micro text-muted-foreground">
Messages longer than this will be summarized.
</p>
<input
type="range"
min={50}
max={2000}
step={50}
value={summaryThreshold}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui text-foreground">
Summary length
</label>
<span className="typography-micro text-muted-foreground tabular-nums">{summaryLength} chars</span>
</div>
<p className="typography-micro text-muted-foreground">
Target length of the summary.
</p>
<input
type="range"
min={20}
max={500}
step={10}
value={summaryLength}
onChange={(e) => 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"
/>
</div>
</>
) : (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui text-foreground">
Max last message length
</label>
<span className="typography-micro text-muted-foreground tabular-nums">{maxLastMessageLength} chars</span>
</div>
<p className="typography-micro text-muted-foreground">
Truncate {'{last_message}'} to this many characters.
</p>
<input
type="range"
min={50}
max={1000}
step={10}
value={maxLastMessageLength}
onChange={(e) => 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"
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={nativeNotificationsEnabled && canShowNotifications}
onClick={() => {
void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
}}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
}
}}
>
<Checkbox
checked={nativeNotificationsEnabled && canShowNotifications}
onChange={(checked) => {
void handleToggleChange(checked);
}}
ariaLabel="Enable notifications"
/>
<span className="typography-ui-label text-foreground">Enable Notifications</span>
</div>
{nativeNotificationsEnabled && canShowNotifications && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notificationMode === 'always'}
onClick={() => setNotificationMode(notificationMode === 'always' ? 'hidden-only' : 'always')}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotificationMode(notificationMode === 'always' ? 'hidden-only' : 'always');
}
}}
>
<Checkbox
checked={notificationMode === 'always'}
onChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
ariaLabel="Notify while app is focused"
/>
<span className="typography-ui-label text-foreground">Notify While App is Focused</span>
</div>
)}
</section>
{isBrowser && (
<div className="mt-1 px-2">
<p className="typography-meta text-muted-foreground/70">
Your browser may ask for permission the first time.
</p>
{notificationPermission === 'denied' && (
<p className="typography-meta text-[var(--status-error)] mt-1">
Notification permission denied. Enable it in your browser settings.
</p>
)}
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
<p className="typography-meta text-muted-foreground/70 mt-1">
Permission granted, but notifications are disabled.
</p>
)}
</div>
)}
{isVSCode && (
<div className="mt-1 px-2">
<p className="typography-meta text-muted-foreground/70">
VS Code runtime handles notifications separately natively.
</p>
</div>
)}
</div>
)}
{isBrowser && (
<>
{notificationPermission === 'denied' && (
<p className="typography-micro text-destructive">
Notification permission denied. Enable it in your browser settings.
</p>
)}
{nativeNotificationsEnabled && canShowNotifications && (
<>
{/* --- Events --- */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Notification Events
</h3>
</div>
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
<p className="typography-micro text-muted-foreground">
Permission granted, but notifications are disabled.
</p>
)}
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notifyOnCompletion}
onClick={() => setNotifyOnCompletion(!notifyOnCompletion)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotifyOnCompletion(!notifyOnCompletion);
}
}}
>
<Checkbox checked={notifyOnCompletion} onChange={setNotifyOnCompletion} ariaLabel="Agent completion" />
<span className="typography-ui-label text-foreground">Agent Completion</span>
</div>
<div className="space-y-1 pt-4">
<h3 className="typography-ui-header font-semibold text-foreground">
Background (Push)
</h3>
<p className="typography-ui text-muted-foreground">
Get notified even if this page is closed.
</p>
</div>
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notifyOnSubtasks}
onClick={() => setNotifyOnSubtasks(!notifyOnSubtasks)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotifyOnSubtasks(!notifyOnSubtasks);
}
}}
>
<Checkbox checked={notifyOnSubtasks} onChange={setNotifyOnSubtasks} ariaLabel="Subagent completion" />
<span className="typography-ui-label text-foreground">Subagent Completion</span>
</div>
{!pushSupported ? (
<p className="typography-micro text-muted-foreground">
Push not supported in this browser.
</p>
) : (
<p className="typography-micro text-muted-foreground">
Desktop Chrome/Edge and Android support push. iOS requires an installed PWA.
</p>
)}
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notifyOnError}
onClick={() => setNotifyOnError(!notifyOnError)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotifyOnError(!notifyOnError);
}
}}
>
<Checkbox checked={notifyOnError} onChange={setNotifyOnError} ariaLabel="Agent errors" />
<span className="typography-ui-label text-foreground">Agent Errors</span>
</div>
{pushSupported && (
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Enable push notifications
</span>
<p className="typography-micro text-muted-foreground">
Clicking a notification opens the relevant session.
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notifyOnQuestion}
onClick={() => setNotifyOnQuestion(!notifyOnQuestion)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotifyOnQuestion(!notifyOnQuestion);
}
}}
>
<Checkbox checked={notifyOnQuestion} onChange={setNotifyOnQuestion} ariaLabel="Agent questions" />
<span className="typography-ui-label text-foreground">Agent Questions</span>
</div>
</section>
</div>
{/* --- Template Customization --- */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Notification Templates
</h3>
<p className="typography-meta text-muted-foreground mt-0.5">
Variables: <code className="text-[var(--primary-base)]">{'{project_name}'}</code> <code className="text-[var(--primary-base)]">{'{worktree}'}</code> <code className="text-[var(--primary-base)]">{'{branch}'}</code> <code className="text-[var(--primary-base)]">{'{session_name}'}</code> <code className="text-[var(--primary-base)]">{'{agent_name}'}</code> <code className="text-[var(--primary-base)]">{'{model_name}'}</code> <code className="text-[var(--primary-base)]">{'{last_message}'}</code>
</p>
</div>
<div className="flex items-center gap-2">
{pushBusy && (
<div className="text-muted-foreground">
<GridLoader size="sm" />
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3">
{(['completion', 'subtask', 'error', 'question'] as const).map((event) => (
<section key={event} className="p-2">
<span className="typography-ui-label text-foreground font-normal capitalize block">
{event === 'subtask' ? 'Subagent Completion' : event}
</span>
<div className="mt-1.5 space-y-2">
<div>
<label className="typography-micro text-muted-foreground block mb-1">Title</label>
<Input
value={notificationTemplates[event].title}
onChange={(e) => updateTemplate(event, 'title', e.target.value)}
className="h-7"
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].title}
/>
</div>
<div>
<label className="typography-micro text-muted-foreground block mb-1">Message</label>
<Input
value={notificationTemplates[event].message}
onChange={(e) => updateTemplate(event, 'message', e.target.value)}
className="h-7"
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].message}
/>
</div>
</div>
</section>
))}
</div>
</div>
{/* --- Summarization --- */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
AI Summarization
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={summarizeLastMessage}
onClick={() => setSummarizeLastMessage(!summarizeLastMessage)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setSummarizeLastMessage(!summarizeLastMessage);
}
}}
>
<Checkbox
checked={summarizeLastMessage}
onChange={setSummarizeLastMessage}
ariaLabel="Summarize last message"
/>
<span className="typography-ui-label text-foreground">Summarize Last Message</span>
</div>
{summarizeLastMessage ? (
<>
<div className="flex items-center gap-8 py-1.5 mt-1 border-t border-[var(--surface-subtle)]">
<div className="flex min-w-0 flex-col w-56 shrink-0">
<span className="typography-ui-label text-foreground">Threshold</span>
<span className="typography-meta text-muted-foreground">Messages longer than this will be summarized</span>
</div>
<div className="flex items-center gap-2 w-fit">
<NumberInput
value={summaryThreshold}
onValueChange={setSummaryThreshold}
min={50}
max={2000}
step={50}
className="w-20 tabular-nums"
/>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => 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"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
<div className="flex items-center gap-8 py-1.5">
<div className="flex min-w-0 flex-col w-56 shrink-0">
<span className="typography-ui-label text-foreground">Length</span>
<span className="typography-meta text-muted-foreground">Target character length of the summary</span>
</div>
<div className="flex items-center gap-2 w-fit">
<NumberInput
value={summaryLength}
onValueChange={setSummaryLength}
min={20}
max={500}
step={10}
className="w-20 tabular-nums"
/>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => 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"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
</>
) : (
<div className={cn("py-1.5 mt-1 border-t border-[var(--surface-subtle)]", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<span className="typography-ui-label text-foreground">Max Length</span>
<span className="typography-meta text-muted-foreground">Truncate {'{last_message}'} to this length</span>
</div>
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
<NumberInput
value={maxLastMessageLength}
onValueChange={setMaxLastMessageLength}
min={50}
max={1000}
step={10}
className="w-20 tabular-nums"
/>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => 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"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
)}
</section>
</div>
</>
)}
<Switch
checked={pushSubscribed}
disabled={pushBusy}
onCheckedChange={(checked: boolean) => {
{/* --- Background Push Notifications --- */}
{isBrowser && (
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Background Push Notifications
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<div className="flex items-start gap-2 py-1.5">
<Checkbox
checked={pushSupported ? pushSubscribed : false}
disabled={!pushSupported || pushBusy}
onChange={(checked: boolean) => {
if (checked) {
void handleEnableBackgroundNotifications();
} else {
void handleDisableBackgroundNotifications();
}
}}
className="data-[state=checked]:bg-status-info"
ariaLabel="Enable push notifications"
/>
<div className="flex min-w-0 flex-col">
<span className={cn("typography-ui-label", !pushSupported ? "text-muted-foreground" : "text-foreground")}>Enable push notifications</span>
<span className="typography-meta text-muted-foreground">
{!pushSupported
? "Push not supported. Desktop Chrome/Edge and Android support push. iOS requires an installed PWA."
: "Receive alerts via your operating system background service"}
</span>
</div>
{pushBusy && (
<div className="pt-0.5 text-muted-foreground">
<GridLoader size="sm" />
</div>
)}
</div>
</div>
)}
</>
)}
</section>
</div>
)}
{isVSCode && (
<div className="space-y-1 pt-4">
<h3 className="typography-ui-header font-semibold text-foreground">
Delivery
</h3>
<p className="typography-ui text-muted-foreground">
VS Code runtime handles notifications separately.
</p>
</div>
)}
</div>
);
};
@@ -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<OpenChamberPageProps> = ({ section }) =>
outerClassName="h-full"
className="w-full"
>
<div className="openchamber-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6">
<div className="openchamber-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6 sm:pt-8">
<OpenChamberVisualSettings />
<div className="border-t border-border/40 pt-6">
<DefaultsSettings />
@@ -87,7 +86,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
outerClassName="h-full"
className="w-full"
>
<div className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6">
<div className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
{renderSectionContent()}
</div>
</ScrollableOverlay>
@@ -134,15 +133,6 @@ const GitSectionContent: React.FC = () => {
return (
<div className="space-y-6">
<GitSettings />
<div className="border-t border-border/40 pt-6">
<div className="space-y-1 mb-4">
<h3 className="typography-ui-header font-semibold text-foreground">Worktree</h3>
<p className="typography-meta text-muted-foreground">
Configure worktree branch defaults and manage existing worktrees.
</p>
</div>
<WorktreeSectionContent />
</div>
</div>
);
};
@@ -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<OpenChamberSidebarProps> = ({
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 (
<div className={cn('grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto]', bgClass)}>
<div className="min-h-0">
<ScrollableOverlay outerClassName="h-full" className="space-y-1 px-3 py-2 overflow-x-hidden">
{visibleSections.map((group) => {
const isSelected = selectedSection === group.id;
return (
<div
key={group.id}
className={cn(
'group relative rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
>
<button
onClick={() => 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"
>
<div className="flex items-center gap-2">
<span className="typography-ui-label font-normal text-foreground">
{group.label}
</span>
{group.badge && (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border border-[var(--status-warning-border)] px-1.5 py-0.5 rounded">
{group.badge}
</span>
)}
</div>
<div className="typography-micro text-muted-foreground/60 leading-tight">
{group.items.join(' · ')}
</div>
</button>
</div>
);
})}
</ScrollableOverlay>
</div>
{(showReload || showAbout) && (
<div className={cn(
'border-t border-border bg-sidebar',
showAbout ? 'px-3 py-3 space-y-3' : 'flex-shrink-0 h-12 px-2'
)}>
{showAbout ? (
<>
{showReload && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex h-8 w-full items-center gap-2 rounded-md px-2',
'text-sm font-semibold text-sidebar-foreground/90',
'hover:text-sidebar-foreground hover:bg-interactive-hover',
'transition-all duration-200',
'disabled:pointer-events-none disabled:opacity-50'
)}
onClick={() => void handleReloadConfiguration()}
disabled={isReloadingConfig}
>
<RiRestartLine className="h-4 w-4" />
{isReloadingConfig ? 'Reloading OpenCode…' : 'Reload OpenCode'}
</button>
</TooltipTrigger>
<TooltipContent>
Restart OpenCode and reload its configuration (agents, commands, skills, providers).
</TooltipContent>
</Tooltip>
)}
<AboutSettings />
</>
) : (
<div className="flex h-full items-center justify-between gap-2">
{showReload && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex h-8 items-center gap-2 rounded-md px-2',
'text-sm font-semibold text-sidebar-foreground/90',
'hover:text-sidebar-foreground hover:bg-interactive-hover',
'transition-all duration-200',
'disabled:pointer-events-none disabled:opacity-50'
)}
onClick={() => void handleReloadConfiguration()}
disabled={isReloadingConfig}
>
<RiRestartLine className="h-4 w-4" />
{isReloadingConfig ? 'Reloading OpenCode…' : 'Reload OpenCode'}
</button>
</TooltipTrigger>
<TooltipContent>
Restart OpenCode and reload its configuration (agents, commands, skills, providers).
</TooltipContent>
</Tooltip>
)}
<div />
</div>
)}
</div>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -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 (
<div className="space-y-3">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">OpenCode CLI</h3>
<p className="typography-meta text-muted-foreground">
Optional absolute path to the <code className="font-mono text-xs">opencode</code> binary.
Useful when your desktop app launch environment has a stale PATH.
If your <code className="font-mono text-xs">opencode</code> shim requires Node/Bun (e.g. <code className="font-mono text-xs">env node</code> or <code className="font-mono text-xs">env bun</code>), make sure that runtime is installed.
</p>
<div className="mb-8">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
OpenCode CLI
</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Optional absolute path to the <code className="font-mono text-xs">opencode</code> binary.
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex gap-2">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="/Users/you/.bun/bin/opencode"
disabled={isLoading || isSaving}
className="flex-1 font-mono text-xs"
/>
<Button
type="button"
variant="secondary"
onClick={handleBrowse}
disabled={isLoading || isSaving || !isDesktopShell() || !isTauriShell()}
>
Browse
</Button>
<Button
type="button"
onClick={handleSaveAndReload}
disabled={isLoading || isSaving}
>
{isSaving ? 'Saving…' : 'Save + Reload'}
</Button>
</div>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-3">
<div className="flex min-w-0 flex-col shrink-0">
<span className="typography-ui-label text-foreground">OpenCode Binary Path</span>
</div>
<div className="flex min-w-0 items-center gap-2 sm:w-[20rem]">
<Input
value={value}
onChange={(e) => 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"
/>
<ButtonSmall
type="button"
variant="outline"
size="xs"
onClick={handleBrowse}
disabled={isLoading || isSaving || !isDesktopShell() || !isTauriShell()}
className="h-7 w-7 p-0"
aria-label="Browse for OpenCode binary path"
title="Browse"
>
<RiFolderLine className="h-4 w-4" />
</ButtonSmall>
</div>
</div>
<div className="typography-micro text-muted-foreground">
Tip: you can also use <span className="font-mono">OPENCODE_BINARY</span> env var, but this setting persists in
<span className="font-mono"> ~/.config/openchamber/settings.json</span>.
</div>
<div className="py-1.5">
<div className="typography-micro text-muted-foreground/70">
Tip: you can also use <span className="font-mono">OPENCODE_BINARY</span> env var, but this setting persists in <span className="font-mono">~/.config/openchamber/settings.json</span>.
</div>
</div>
<div className="flex justify-start py-1.5">
<ButtonSmall
type="button"
size="xs"
onClick={handleSaveAndReload}
disabled={isLoading || isSaving}
className="shrink-0 !font-normal"
>
{isSaving ? 'Saving…' : 'Save + Reload'}
</ButtonSmall>
</div>
</section>
</div>
);
};
@@ -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 (
<div className="space-y-4">
<div className="space-y-1">
<div className="mb-8">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Session retention</h3>
<h3 className="typography-ui-header font-medium text-foreground">
Session Retention
</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Automatically delete inactive sessions based on their last activity.<br />
You can also run a one-time cleanup without enabling auto-cleanup.<br />
Keeps the most recent 5 sessions, and never deletes shared sessions.
Automatically delete inactive sessions based on their last activity. Keeps recent 5 sessions.
</TooltipContent>
</Tooltip>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={autoDeleteEnabled}
onChange={setAutoDeleteEnabled}
/>
<span className="typography-ui-header font-semibold text-foreground">Enable auto-cleanup</span>
</label>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={autoDeleteEnabled}
onClick={() => setAutoDeleteEnabled(!autoDeleteEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setAutoDeleteEnabled(!autoDeleteEnabled);
}
}}
>
<Checkbox
checked={autoDeleteEnabled}
onChange={setAutoDeleteEnabled}
ariaLabel="Enable auto-cleanup"
/>
<span className="typography-ui-label text-foreground">Enable Auto-Cleanup</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
{isMobile ? (
<input
type="number"
inputMode="numeric"
value={mobileDraftDays}
onChange={(event) => {
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"
/>
) : (
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Retention Period</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<NumberInput
value={autoDeleteAfterDays}
onValueChange={setAutoDeleteAfterDays}
@@ -113,22 +87,45 @@ export const SessionRetentionSettings: React.FC = () => {
max={MAX_DAYS}
step={1}
aria-label="Retention period in days"
className="w-20 tabular-nums"
/>
)}
<span className="typography-ui-label text-muted-foreground">days since last activity</span>
<span className="typography-ui-label text-muted-foreground">days</span>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => 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"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
<ButtonSmall
type="button"
variant="outline"
onClick={handleRunCleanup}
disabled={isRunning}
>
{isRunning ? 'Cleaning up...' : 'Run cleanup now'}
</ButtonSmall>
</div>
</section>
<div className="typography-meta text-muted-foreground">
Eligible for deletion right now: {pendingCount}
<div className="mt-1 px-2 py-1.5 space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<p className="typography-meta text-foreground font-medium">Manual Cleanup</p>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<ButtonSmall
type="button"
variant="outline"
size="xs"
onClick={handleRunCleanup}
disabled={isRunning}
className="!font-normal"
>
{isRunning ? 'Cleaning up...' : 'Run cleanup now'}
</ButtonSmall>
</div>
</div>
<p className="typography-meta text-muted-foreground">
Eligible for deletion right now: <span className="tabular-nums">{pendingCount}</span>
</p>
</div>
</div>
);
File diff suppressed because it is too large Load Diff
@@ -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<WorktreeSectionContentProps> = ({ 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 (
<div className="space-y-6">
<div className="max-w-[44rem] space-y-5">
{/* Setup commands */}
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">Setup commands</h3>
<p className="typography-meta text-muted-foreground">
Run automatically inside the new worktree directory when a worktree is created.
<br />
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code> for the project root.
</p>
<div className="space-y-2">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-normal text-foreground">Setup commands</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Run automatically inside the new worktree directory when a worktree is created.
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code> for the project root.
</TooltipContent>
</Tooltip>
</div>
</div>
{isLoadingCommands ? (
<p className="typography-meta text-muted-foreground">Loading...</p>
<p className="typography-meta text-muted-foreground px-1">Loading...</p>
) : (
<div className="space-y-2">
<div className="space-y-2 px-1">
{setupCommands.map((command, index) => (
<div key={index} className="flex gap-2">
<div key={index} className="flex w-full gap-2">
<Input
value={command}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => {
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"
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
))}
<button
<ButtonSmall
type="button"
variant="ghost"
size="xs"
className="!font-normal"
onClick={handleAddCommand}
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
>
<RiAddLine className="h-3.5 w-3.5" />
Add command
</button>
</ButtonSmall>
</div>
)}
</div>
{/* Existing worktrees */}
<div className="space-y-4 border-t border-border/40 pt-6">
<div className="space-y-1">
<div className="space-y-2 border-t border-border/40 pt-4">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Existing worktrees</h3>
<h3 className="typography-ui-header font-normal text-foreground">Existing worktrees</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
@@ -316,23 +334,20 @@ export const WorktreeSectionContent: React.FC = () => {
</TooltipContent>
</Tooltip>
</div>
<p className="typography-meta text-muted-foreground">
Manage worktrees for this project
</p>
</div>
{isLoadingWorktrees ? (
<p className="typography-meta text-muted-foreground">Loading worktrees...</p>
<p className="typography-meta text-muted-foreground px-1">Loading worktrees...</p>
) : availableWorktrees.length === 0 ? (
<p className="typography-meta text-muted-foreground/70">
<p className="typography-meta text-muted-foreground/70 px-1">
No worktrees found for this project
</p>
) : (
<div className="space-y-1">
<div className="space-y-1 px-1 max-w-[32.5rem]">
{availableWorktrees.map((worktree) => (
<div
key={worktree.path}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-sidebar-accent/30 transition-colors group"
className="group flex w-full items-center gap-2 py-1.5"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
@@ -347,11 +362,14 @@ export const WorktreeSectionContent: React.FC = () => {
{formatPathForDisplay(worktree.path, homeDirectory)}
</p>
</div>
<button
type="button"
onClick={() => 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}`}
<button
type="button"
onClick={() => 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}`}
>
<RiDeleteBinLine className="h-4 w-4" />
</button>
@@ -0,0 +1,9 @@
export type OpenChamberSection =
| 'visual'
| 'chat'
| 'shortcuts'
| 'sessions'
| 'git'
| 'github'
| 'notifications'
| 'voice';
@@ -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<string | null>(null);
const [color, setColor] = React.useState<string | null>(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 (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
<p className="typography-meta text-muted-foreground">No projects available.</p>
</div>
</ScrollableOverlay>
);
}
const currentColorVar = color ? (COLOR_MAP[color] ?? null) : null;
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
{/* Top Header & Actions */}
<div className="mb-4 flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{selectedProject.label ?? 'Project Settings'}
</h2>
<p className="typography-meta text-muted-foreground truncate" title={selectedProject.path}>
{selectedProject.path}
</p>
</div>
</div>
{/* Identity Controls */}
<div className="mb-8">
<section className="px-2 pb-2 pt-0 space-y-0.5">
{/* Name */}
<div className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Project Name</span>
</div>
<div className="mt-1.5 flex min-w-0 items-center gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
className="h-7 min-w-0 w-full sm:max-w-[19rem]"
/>
</div>
</div>
{/* Color */}
<div className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Accent Color</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setColor(null)}
className={cn(
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
color === null
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
)}
title="None"
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</button>
{PROJECT_COLORS.map((c) => (
<button
key={c.key}
type="button"
onClick={() => 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}
/>
))}
</div>
</div>
{/* Icon */}
<div className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Project Icon</span>
</div>
<div className="mt-1.5 flex max-w-[22rem] flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setIcon(null)}
className={cn(
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
icon === null
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
)}
title="None"
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</button>
{PROJECT_ICONS.map((i) => {
const IconComponent = i.Icon;
return (
<button
key={i.key}
type="button"
onClick={() => 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}
>
<IconComponent className="w-4 h-4" style={currentColorVar && icon === i.key ? { color: currentColorVar } : undefined} />
</button>
);
})}
</div>
</div>
</section>
<div className="mt-0.5 px-2 py-1">
<ButtonSmall
onClick={handleSave}
disabled={!hasChanges || name.trim().length === 0}
size="xs"
className="!font-normal"
>
Save Changes
</ButtonSmall>
</div>
</div>
{/* Worktree Group */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Worktree
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<WorktreeSectionContent projectRef={{ id: selectedProject.id, path: selectedProject.path }} />
</section>
</div>
</div>
</ScrollableOverlay>
);
};
@@ -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 (
<SettingsSidebarLayout
variant="background"
header={
<div className={cn('border-b px-3', 'pt-4 pb-3')}>
<h2 className="text-base font-semibold text-foreground mb-3">Projects</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {projects.length}</span>
{!isVSCode && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleAddProject}
aria-label="Add project"
>
<RiAddLine className="size-4" />
</Button>
)}
</div>
</div>
}
>
{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
? (
<Icon className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
)
: (
<RiFolderLine className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
);
return (
<SettingsSidebarItem
key={project.id}
title={project.label || project.path}
icon={icon}
selected={selected}
onSelect={() => {
setSelectedId(project.id);
onItemSelect?.();
}}
/>
);
})}
</SettingsSidebarLayout>
);
};
File diff suppressed because it is too large Load Diff
@@ -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<ProvidersSidebarProps> = ({ 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<Record<string, ProviderSources>>({});
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 (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Providers</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {providers.length}</span>
<Button
type="button"
<ButtonSmall
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={() => {
setSelectedProvider(ADD_PROVIDER_ID);
onItemSelect?.();
@@ -41,8 +120,8 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
aria-label="Connect provider"
title="Connect provider"
>
<RiAddLine className="size-4" />
</Button>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
@@ -54,40 +133,81 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
</div>
) : (
providers.map((provider) => {
const modelCount = Array.isArray(provider.models) ? provider.models.length : 0;
const isSelected = provider.id === selectedProviderId;
<>
{userProviders.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
User Providers
</div>
{userProviders.map((provider) => (
<ProviderListItem
key={provider.id}
provider={provider}
selectedProviderId={selectedProviderId}
onSelect={() => {
setSelectedProvider(provider.id);
onItemSelect?.();
}}
/>
))}
</>
)}
return (
<div
key={provider.id}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
>
<button
type="button"
onClick={() => {
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}
>
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
{provider.name || provider.id}
</span>
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">
{modelCount}
</span>
</button>
</div>
);
})
{projectProviders.length > 0 && (
<>
<div className={cn('px-2 pb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground', userProviders.length > 0 ? 'pt-3' : 'pt-2')}>
Project Providers
</div>
{projectProviders.map((provider) => (
<ProviderListItem
key={provider.id}
provider={provider}
selectedProviderId={selectedProviderId}
onSelect={() => {
setSelectedProvider(provider.id);
onItemSelect?.();
}}
/>
))}
</>
)}
</>
)}
</ScrollableOverlay>
</div>
);
};
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 (
<div
key={provider.id}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
>
<button
type="button"
onClick={onSelect}
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}
>
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
{provider.name || provider.id}
</span>
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">
{modelCount}
</span>
</button>
</div>
);
};
@@ -37,7 +37,7 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
>
<div
className={cn(
'mx-auto max-w-3xl space-y-6 p-3 sm:p-6',
'mx-auto max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8',
className
)}
>
@@ -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 (
<div className={cn(className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Switch project"
title="Switch project"
className={cn(
// Mirror Input sizing so headers align visually.
'text-foreground border border-border/80 appearance-none flex h-8 w-full min-w-0 rounded-lg bg-transparent px-3 py-1 outline-none',
'hover:border-input focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:border-primary/70',
'flex items-center gap-1.5 text-left'
)}
>
<RiFolderLine className="h-4 w-4 opacity-70" />
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium">{label}</span>
<RiArrowDownSLine className="size-4 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuRadioGroup
value={activeProject?.id ?? ''}
onValueChange={(value) => {
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 (
<DropdownMenuRadioItem key={project.id} value={project.id}>
<span className="min-w-0 truncate typography-ui">{itemLabel}</span>
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
@@ -68,7 +68,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
'group relative flex items-center rounded-md px-1.5 py-0.5 transition-all duration-200',
selected
? 'bg-interactive-selection'
: 'hover:bg-interactive-hover',
@@ -12,6 +12,8 @@ interface SettingsSidebarLayoutProps {
children: React.ReactNode;
/** Additional className for the outer container */
className?: string;
/** Background style for the sidebar container */
variant?: 'sidebar' | 'background';
}
/**
@@ -32,13 +34,50 @@ export const SettingsSidebarLayout: React.FC<SettingsSidebarLayoutProps> = ({
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<HTMLElement | null>(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 (
<div
@@ -50,12 +89,28 @@ export const SettingsSidebarLayout: React.FC<SettingsSidebarLayoutProps> = ({
>
{header}
<ScrollableOverlay
outerClassName="flex-1 min-h-0"
className="space-y-1 px-3 py-2 overflow-x-hidden"
>
{children}
</ScrollableOverlay>
<div className="relative flex flex-1 min-h-0 flex-col">
<ScrollableOverlay
ref={scrollRef as unknown as React.Ref<HTMLElement>}
outerClassName="flex-1 min-h-0"
className="space-y-0.5 px-3 py-2 overflow-x-hidden"
>
{children}
</ScrollableOverlay>
{showTopShadow && (
<div
className="pointer-events-none absolute inset-x-0 top-0 h-4"
style={{ background: `linear-gradient(to bottom, ${bgVar} 0%, transparent 100%)` }}
/>
)}
{showBottomShadow && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-6"
style={{ background: `linear-gradient(to top, ${bgVar} 0%, transparent 100%)` }}
/>
)}
</div>
{footer}
</div>
@@ -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 = () => (
<SkillsCatalogPage mode="external" onModeChange={() => {}} 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<SkillsMode>('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 ? (
<AnimatedTabs
tabs={[
{ value: 'manual', label: 'Manual' },
{ value: 'external', label: 'External' },
]}
value={mode}
onValueChange={setMode}
animate={false}
/>
) : null;
const [draftName, setDraftName] = React.useState('');
const [draftScope, setDraftScope] = React.useState<SkillScope>('user');
const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode');
const [description, setDescription] = React.useState('');
const [instructions, setInstructions] = React.useState('');
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]); // For new skills
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]);
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<string | null>(null); // null = adding, string = editing
const [editingFilePath, setEditingFilePath] = React.useState<string | null>(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<string | null>(null);
const [isDeletingFile, setIsDeletingFile] = React.useState(false);
const [instructionsEditorHeight, setInstructionsEditorHeight] = React.useState(320);
const [instructionsLanguage, setInstructionsLanguage] = React.useState<Extension | null>(null);
const [supportingFileLanguage, setSupportingFileLanguage] = React.useState<Extension | null>(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<HTMLDivElement>) => {
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 <SkillsCatalogPage mode={mode} onModeChange={setMode} />;
}
// Show empty state when nothing is selected or selection is stale
if ((!selectedSkillName && !skillDraft) || hasStaleSelection) {
return (
<div className="flex h-full items-center justify-center">
@@ -473,225 +342,185 @@ export const SkillsPage: React.FC = () => {
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
{isNewSkill ? modeTabs : null}
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{isNewSkill ? 'New Skill' : selectedSkillName}
</h1>
{selectedSkill && (
<p className="typography-meta text-muted-foreground">
{locationLabel(selectedSkill.scope, selectedSkill.source)} skill
{selectedSkill.source === 'claude' && ' (Claude-compatible)'}
</p>
)}
</div>
{/* Basic Information */}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2>
<p className="typography-meta text-muted-foreground/80">
Configure skill identity and description
</p>
</div>
{isNewSkill && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Skill Name & Location
</label>
<div className="flex items-center gap-2">
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
placeholder="skill-name"
className="flex-1 text-foreground placeholder:text-muted-foreground"
/>
<Select
value={locationValueFrom(draftScope, draftSource)}
onValueChange={(v) => {
const next = locationPartsFrom(v as SkillLocationValue);
setDraftScope(next.scope);
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<SelectTrigger className="!h-9 w-auto gap-1.5">
{draftScope === 'user' ? (
<RiUser3Line className="h-4 w-4" />
) : (
<RiFolderLine className="h-4 w-4" />
)}
{draftSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
<span>{locationLabel(draftScope, draftSource)}</span>
</SelectTrigger>
<SelectContent align="end">
{SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
<span>{option.label}</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="typography-meta text-muted-foreground">
Lowercase letters, numbers, and hyphens only. Cannot start or end with hyphen.
</p>
</div>
)}
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Description <span className="text-destructive">*</span>
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does..."
rows={2}
className="max-h-32 resize-none"
/>
<p className="typography-meta text-muted-foreground">
The agent uses this to decide when to load the skill
</p>
</div>
</div>
{/* Instructions */}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Instructions</h2>
<p className="typography-meta text-muted-foreground/80">
Detailed instructions for the agent when this skill is loaded
</p>
</div>
<div
className="relative min-h-[220px] max-h-[75vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden flex flex-col"
style={{ height: `${instructionsEditorHeight}px` }}
>
<div className="flex-1 min-h-0">
<React.Suspense
fallback={(
<Textarea
value={instructions}
onChange={(e) => 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 */}
<div className="mb-4">
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
{isNewSkill ? 'New Skill' : selectedSkillName}
{selectedSkill?.source === 'claude' && (
<span className="typography-micro font-normal bg-[var(--surface-muted)] text-muted-foreground px-1.5 py-0.5 rounded">
Claude-compatible
</span>
)}
>
<LazyCodeMirrorEditor
value={instructions}
onChange={setInstructions}
extensions={instructionsEditorExtensions}
className="h-full"
/>
</React.Suspense>
</div>
<div
role="separator"
aria-label="Resize instructions editor"
onMouseDown={handleStartInstructionsResize}
className="absolute right-1.5 bottom-1.5 z-10 h-4 w-4 cursor-nwse-resize opacity-80 hover:opacity-100"
>
<svg viewBox="0 0 16 16" className="h-4 w-4 text-[var(--surface-muted-foreground)]" aria-hidden="true">
<path d="M6 14L14 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M10 14L14 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M2 14L14 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
</div>
</div>
{/* Supporting Files */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Supporting Files</h2>
<p className="typography-meta text-muted-foreground/80">
Reference documentation, scripts, or templates
</h2>
<p className="typography-meta text-muted-foreground truncate">
{selectedSkill ? `${locationLabel(selectedSkill.scope, selectedSkill.source)} skill` : 'Configure a new skill'}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddFile}
className="gap-1.5"
>
<RiAddLine className="h-3.5 w-3.5" />
Add File
</Button>
</div>
{(() => {
// For new skills, show pending files
const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
if (filesToShow.length === 0) {
return (
<p className="typography-meta text-muted-foreground py-2">
{isNewSkill ? 'No files yet. Use "Add File" to include reference materials.' : 'No supporting files. Use "Add File" to include reference materials.'}
</p>
);
}
return (
<div className="space-y-2">
{filesToShow.map((file) => (
<div
key={file.path}
className="flex items-center justify-between px-3 py-2 rounded-lg border bg-muted/30 hover:bg-interactive-hover cursor-pointer transition-colors"
onClick={() => handleEditFile(file.path)}
>
<div className="flex items-center gap-2 min-w-0">
<RiFileLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="typography-ui-label truncate">{file.path}</span>
{isNewSkill && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
pending
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(file.path);
{/* Basic Information */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Basic Information
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewSkill && (
<div className="py-1.5">
<span className="typography-ui-label text-foreground">Skill Name & Location</span>
<span className="typography-meta text-muted-foreground ml-2">Lowercase, numbers, hyphens</span>
<div className="flex items-center gap-2 mt-1.5">
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
placeholder="skill-name"
className="h-7 w-40 px-2"
/>
<Select
value={locationValueFrom(draftScope, draftSource)}
onValueChange={(v) => {
const next = locationPartsFrom(v as SkillLocationValue);
setDraftScope(next.scope);
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</Button>
<SelectTrigger className="w-fit gap-1.5">
{draftScope === 'user' ? (
<RiUser3Line className="h-3.5 w-3.5" />
) : (
<RiFolderLine className="h-3.5 w-3.5" />
)}
{draftSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
<span>{locationLabel(draftScope, draftSource)}</span>
</SelectTrigger>
<SelectContent align="start">
{SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
{option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
<span>{option.label}</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
);
})()}
</div>
</div>
)}
<div className="py-1.5">
<span className="typography-ui-label text-foreground">Description <span className="text-[var(--status-error)]">*</span></span>
<span className="typography-meta text-muted-foreground ml-2">The agent uses this to decide when to load the skill</span>
<div className="mt-1.5">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does..."
rows={2}
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
/>
</div>
</div>
</section>
</div>
{/* Instructions */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Instructions
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Step-by-step instructions, guidelines, or reference content..."
className="min-h-[220px] max-h-[60vh] font-mono typography-meta"
/>
</section>
</div>
{/* Supporting Files */}
<div className="mb-2">
<div className="mb-1 px-1 flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
Supporting Files
</h3>
<ButtonSmall variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
<RiAddLine className="h-3.5 w-3.5" /> Add File
</ButtonSmall>
</div>
<section className="px-2 pb-2 pt-0">
{(() => {
const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
if (filesToShow.length === 0) {
return (
<p className="typography-meta text-muted-foreground py-1.5">
No supporting files. Use "Add File" to include reference materials.
</p>
);
}
return (
<div className="divide-y divide-[var(--surface-subtle)]">
{filesToShow.map((file) => (
<div
key={file.path}
className="flex items-center gap-2 py-1.5 cursor-pointer group"
onClick={() => handleEditFile(file.path)}
>
<RiFileLine className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="typography-ui-label text-foreground truncate">{file.path}</span>
{isNewSkill && (
<span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
pending
</span>
)}
<ButtonSmall
variant="ghost"
className="h-5 w-5 px-0 flex-shrink-0 text-muted-foreground hover:text-[var(--status-error)] opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(file.path);
}}
>
<RiDeleteBinLine className="h-3 w-3" />
</ButtonSmall>
</div>
))}
</div>
);
})()}
</section>
</div>
{/* Save action */}
<div className="px-2 py-1">
<ButtonSmall
onClick={handleSave}
disabled={isSaving || !hasSkillChanges}
size="xs"
className="!font-normal"
>
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
</ButtonSmall>
</div>
{/* Save Button */}
<div className="flex justify-end border-t border-border/40 pt-4">
<Button
size="sm"
variant="default"
onClick={handleSave}
disabled={isSaving || !hasSkillChanges}
className="gap-2 h-6 px-2 text-xs w-fit"
>
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
</Button>
</div>
{/* Add/Edit File Dialog */}
@@ -711,15 +540,14 @@ export const SkillsPage: React.FC = () => {
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={() => setDeleteFilePath(null)}
disabled={isDeletingFile}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
<ButtonLarge onClick={handleConfirmDeleteFile} disabled={isDeletingFile}>
</ButtonLarge>
<ButtonLarge onClick={handleConfirmDeleteFile} disabled={isDeletingFile} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
Delete
</ButtonLarge>
</DialogFooter>
@@ -730,7 +558,7 @@ export const SkillsPage: React.FC = () => {
setIsFileDialogOpen(open);
if (!open) setEditingFilePath(null);
}}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col" keyboardAvoid>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col" keyboardAvoid>
<DialogHeader className="flex-shrink-0">
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
<DialogDescription>
@@ -742,7 +570,7 @@ export const SkillsPage: React.FC = () => {
<span className="typography-meta text-muted-foreground">Loading file content...</span>
</div>
) : (
<div className="space-y-4 flex-1 min-h-0 flex flex-col">
<div className="space-y-4 flex-1 min-h-0 flex flex-col pt-2">
<div className="space-y-2 flex-shrink-0">
<label className="typography-ui-label font-medium text-foreground">
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 = () => {
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
Content
</label>
<div className="h-[45vh] min-h-[220px] max-h-[55vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden">
<React.Suspense
fallback={(
<Textarea
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
placeholder="File content..."
className="h-full border-0 rounded-none font-mono typography-meta resize-none"
/>
)}
>
<LazyCodeMirrorEditor
value={newFileContent}
onChange={setNewFileContent}
extensions={supportingFileEditorExtensions}
className="h-full"
/>
</React.Suspense>
</div>
<Textarea
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
placeholder="File content..."
outerClassName="h-[45vh] min-h-[250px] max-h-[55vh]"
className="h-full min-h-0 font-mono typography-meta"
/>
</div>
</div>
)}
<DialogFooter>
<Button
<DialogFooter className="mt-4">
<ButtonLarge
variant="ghost"
onClick={() => {
setIsFileDialogOpen(false);
setEditingFilePath(null);
}}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
{editingFilePath ? 'Save Changes' : 'Create File'}
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ScrollableOverlay>
);
};
export const SkillsPage: React.FC<SkillsPageProps> = ({ view = 'installed' }) => {
return view === 'catalog' ? <SkillsCatalogStandalone /> : <SkillsInstalledPage />;
};
@@ -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<SkillsSidebarProps> = ({ onItemSelect }) =>
const [renameNewName, setRenameNewName] = React.useState('');
const [deleteDialogSkill, setDeleteDialogSkill] = React.useState<DiscoveredSkill | null>(null);
const [isDeletePending, setIsDeletePending] = React.useState(false);
const [openMenuSkill, setOpenMenuSkill] = React.useState<string | null>(null);
const {
selectedSkillName,
@@ -43,20 +43,12 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ 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<SkillsSidebarProps> = ({ onItemSelect }) =>
setSelectedSkill(newName);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteSkill = async (skill: DiscoveredSkill) => {
@@ -125,9 +115,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
});
setSelectedSkill(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
@@ -214,18 +202,18 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Skills</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {skills.length}</span>
<Button
type="button"
<ButtonSmall
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="size-4" />
</Button>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
@@ -258,13 +246,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ 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)}
/>
))}
</SidebarGroup>
@@ -277,13 +265,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ 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<SkillsSidebarProps> = ({ 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)}
/>
))}
</SidebarGroup>
@@ -328,13 +316,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ 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<SkillsSidebarProps> = ({ onItemSelect }) =>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={() => setDeleteDialogSkill(null)}
disabled={isDeletePending}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
Delete
</ButtonLarge>
@@ -395,13 +383,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}}
/>
<DialogFooter>
<Button
<ButtonLarge
variant="ghost"
onClick={() => setRenameDialogSkill(null)}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge onClick={handleRenameSkill}>
Rename
</ButtonLarge>
@@ -419,6 +407,8 @@ interface SkillListItemProps {
onDelete: () => void;
onRename: () => void;
onDuplicate: () => void;
isMenuOpen: boolean;
onMenuOpenChange: (open: boolean) => void;
}
const SkillListItem: React.FC<SkillListItemProps> = ({
@@ -428,13 +418,20 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
onDelete,
onRename,
onDuplicate,
isMenuOpen,
onMenuOpenChange,
}) => {
const isMobile = isMobileDeviceViaCSS();
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
>
<div className="flex min-w-0 flex-1 items-center">
<button
@@ -462,15 +459,14 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
</div>
</button>
<DropdownMenu>
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
<ButtonSmall
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</ButtonSmall>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -240,12 +240,12 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
<div className="space-y-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Catalog name</label>
<label className="typography-ui-label text-foreground">Catalog name</label>
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Team Skills" />
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Repository</label>
<label className="typography-ui-label text-foreground">Repository</label>
<Input
value={source}
onChange={(e) => {
@@ -261,7 +261,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label>
<label className="typography-ui-label text-foreground">Optional subpath</label>
<Input
value={subpath}
onChange={(e) => {
@@ -274,28 +274,26 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div>
{identityOptions.length > 0 && !isVSCodeRuntime() ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="typography-ui-label font-medium text-foreground">Authentication required</div>
<div className="typography-meta text-muted-foreground mt-1">
Select a Git identity (SSH key) that can access this repository.
</div>
<div className="mt-2">
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="!h-9 w-full justify-between">
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger>
<SelectContent align="start">
{identityOptions.map((id) => (
<SelectItem key={id.id} value={id.id} className="pr-2 [&>span:first-child]:hidden">
{id.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="typography-micro text-muted-foreground mt-2">
Configure identities in Settings Git Identities.
<div className="space-y-2">
<div>
<span className="typography-ui-label text-[var(--status-warning)]">Authentication required</span>
<span className="typography-meta text-muted-foreground ml-2">Select a Git identity (SSH key)</span>
</div>
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="w-fit">
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger>
<SelectContent align="start">
{identityOptions.map((id) => (
<SelectItem key={id.id} value={id.id}>
{id.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="typography-micro text-muted-foreground">
Configure identities in Settings - Git Identities.
</p>
</div>
) : null}
@@ -313,25 +311,24 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="outline"
</ButtonLarge>
<ButtonLarge
variant="ghost"
onClick={() => void handleScan()}
disabled={isScanning || !source.trim()}
className="gap-2"
>
<RiGitRepositoryLine className="h-4 w-4" />
{isScanning ? 'Scanning' : 'Scan'}
</Button>
<Button
variant="default"
{isScanning ? 'Scanning...' : 'Scan'}
</ButtonLarge>
<ButtonLarge
onClick={() => void handleAdd()}
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
>
Add catalog
</Button>
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -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<InstallConflictsDialogProps> = ({
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setAll('skip')}>Skip all</Button>
<Button variant="outline" size="sm" onClick={() => setAll('overwrite')}>Overwrite all</Button>
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</ButtonSmall>
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</ButtonSmall>
</div>
</div>
@@ -82,7 +82,7 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
{conflicts.map((conflict) => (
<div
key={conflict.skillName}
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2"
className="flex items-center justify-between gap-3 py-1.5"
>
<div className="min-w-0">
<div className="typography-ui-label truncate">{conflict.skillName}</div>
@@ -95,7 +95,7 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
value={decisions[conflict.skillName] || 'skip'}
onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))}
>
<SelectTrigger className="!h-9 w-36 justify-between">
<SelectTrigger className="w-fit">
<span className="capitalize">{decisions[conflict.skillName] || 'skip'}</span>
</SelectTrigger>
<SelectContent align="end">
@@ -113,9 +113,9 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge
onClick={() => onConfirm(decisions)}
disabled={!canConfirm}
@@ -19,6 +19,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RiFolderLine, RiGitRepositoryLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
@@ -27,6 +28,7 @@ import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
import {
SKILL_LOCATION_OPTIONS,
@@ -49,6 +51,10 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [targetProjectId, setTargetProjectId] = React.useState<string | null>(null);
const [source, setSource] = React.useState('');
const [subpath, setSubpath] = React.useState('');
const [scope, setScope] = React.useState<'user' | 'project'>('user');
@@ -70,6 +76,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
targetSource: 'opencode' | 'agents';
selections: Array<{ skillDir: string }>;
gitIdentityId?: string;
directoryOverride?: string | null;
} | null>(null);
React.useEffect(() => {
@@ -78,6 +85,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setSubpath('');
setScope('user');
setTargetSource('opencode');
setTargetProjectId(activeProjectId);
setItems([]);
setSelected({});
setSearch('');
@@ -89,7 +97,32 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setConflicts([]);
setBaseInstallRequest(null);
}, [open, loadDefaultGitIdentityId]);
}, [open, loadDefaultGitIdentityId, activeProjectId]);
const resolvedTargetProjectId = React.useMemo(() => {
if (projects.length === 0) {
return null;
}
if (targetProjectId && projects.some((p) => p.id === targetProjectId)) {
return targetProjectId;
}
if (activeProjectId && projects.some((p) => p.id === activeProjectId)) {
return activeProjectId;
}
return projects[0]?.id ?? null;
}, [activeProjectId, projects, targetProjectId]);
const directoryOverride = React.useMemo(() => {
if (scope !== 'project') {
return null;
}
const id = resolvedTargetProjectId;
if (!id) {
return null;
}
const project = projects.find((p) => p.id === id);
return project?.path ?? null;
}, [projects, resolvedTargetProjectId, scope]);
const installedByName = React.useMemo(() => {
const map = new Map<string, { scope: 'user' | 'project'; source: 'opencode' | 'claude' | 'agents' }>();
@@ -189,13 +222,22 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
targetSource,
selections: selectedDirs.map((dir) => ({ skillDir: dir })),
gitIdentityId: gitIdentityId || undefined,
directoryOverride,
};
const result = await installSkills({
...request,
conflictPolicy: 'prompt',
conflictDecisions: opts.conflictDecisions,
});
const result = await installSkills(
{
source: request.source,
subpath: request.subpath,
scope: request.scope,
targetSource: request.targetSource,
selections: request.selections,
gitIdentityId: request.gitIdentityId,
conflictPolicy: 'prompt',
conflictDecisions: opts.conflictDecisions,
},
{ directory: request.directoryOverride ?? null }
);
if (result.ok) {
const installedCount = result.installed?.length || 0;
@@ -292,7 +334,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<SelectTrigger className="!h-9 w-full gap-1.5">
<SelectTrigger size="lg" className="w-full gap-1.5">
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
<span>{locationLabel(scope, targetSource)}</span>
@@ -315,6 +357,32 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div>
</div>
{scope === 'project' && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Project</label>
{projects.length === 0 ? (
<p className="typography-meta text-muted-foreground">No projects available</p>
) : (
<Select
value={resolvedTargetProjectId ?? ''}
onValueChange={(v) => setTargetProjectId(v)}
disabled={projects.length === 1}
>
<SelectTrigger size="lg" className="w-full justify-between">
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent align="start">
{projects.map((p) => (
<SelectItem key={p.id} value={p.id} className="pr-2 [&>span:first-child]:hidden">
{p.label || p.path}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{identities.length > 0 && !isVSCodeRuntime() ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="typography-ui-label font-medium text-foreground">Authentication required</div>
@@ -323,7 +391,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div>
<div className="mt-2">
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="!h-9 w-full justify-between">
<SelectTrigger size="lg" className="w-full justify-between">
<span>{identities.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger>
<SelectContent align="start">
@@ -419,11 +487,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</ButtonLarge>
<ButtonLarge
disabled={isInstalling || selectedDirs.length === 0 || !source.trim()}
disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
onClick={() => void doInstall({})}
>
{isInstalling ? 'Installing…' : 'Install selected'}
@@ -9,17 +9,19 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
import {
SKILL_LOCATION_OPTIONS,
@@ -39,6 +41,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
const { installSkills, isInstalling } = useSkillsCatalogStore();
const [scope, setScope] = React.useState<'user' | 'project'>('user');
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [targetProjectId, setTargetProjectId] = React.useState<string | null>(null);
const [conflictsOpen, setConflictsOpen] = React.useState(false);
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
const [baseRequest, setBaseRequest] = React.useState<{
@@ -47,16 +52,43 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope: 'user' | 'project';
targetSource: 'opencode' | 'agents';
skillDir: string;
directoryOverride?: string | null;
} | null>(null);
React.useEffect(() => {
if (!open) return;
setScope('user');
setTargetSource('opencode');
setTargetProjectId(activeProjectId);
setConflictsOpen(false);
setConflicts([]);
setBaseRequest(null);
}, [open]);
}, [open, activeProjectId]);
const resolvedTargetProjectId = React.useMemo(() => {
if (projects.length === 0) {
return null;
}
if (targetProjectId && projects.some((p) => p.id === targetProjectId)) {
return targetProjectId;
}
if (activeProjectId && projects.some((p) => p.id === activeProjectId)) {
return activeProjectId;
}
return projects[0]?.id ?? null;
}, [activeProjectId, projects, targetProjectId]);
const directoryOverride = React.useMemo(() => {
if (scope !== 'project') {
return null;
}
const id = resolvedTargetProjectId;
if (!id) {
return null;
}
const project = projects.find((p) => p.id === id);
return project?.path ?? null;
}, [projects, resolvedTargetProjectId, scope]);
const doInstall = async (request: {
source: string;
@@ -64,6 +96,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope: 'user' | 'project';
targetSource: 'opencode' | 'agents';
skillDir: string;
directoryOverride?: string | null;
conflictDecisions?: Record<string, ConflictDecision>;
}) => {
// Build selection with clawdhub metadata if present
@@ -86,7 +119,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
selections: [selection],
conflictPolicy: 'prompt',
conflictDecisions: request.conflictDecisions,
});
}, { directory: request.directoryOverride ?? null });
if (result.ok) {
toast.success('Skill installed successfully');
@@ -101,6 +134,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope: request.scope,
targetSource: request.targetSource,
skillDir: request.skillDir,
directoryOverride: request.directoryOverride ?? null,
});
setConflicts(result.error.conflicts);
setConflictsOpen(true);
@@ -122,7 +156,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg" keyboardAvoid>
<DialogContent className="max-w-md" keyboardAvoid>
<DialogHeader>
<DialogTitle>Install skill</DialogTitle>
<DialogDescription>
@@ -130,21 +164,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{item.warnings?.length ? (
<div className="rounded-lg border bg-muted/30 px-3 py-2">
<div className="typography-micro text-muted-foreground">Warnings</div>
<ul className="mt-1 space-y-1">
{item.warnings.map((w) => (
<li key={w} className="typography-meta text-muted-foreground">{w}</li>
))}
</ul>
</div>
) : null}
</div>
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
<div className="flex items-center gap-2">
<div className="mt-2 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="typography-ui-label text-foreground">Destination</span>
<Select
value={locationValueFrom(scope, targetSource)}
onValueChange={(v) => {
@@ -153,23 +175,21 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<SelectTrigger className="!h-9 w-full sm:w-auto">
<span className="flex items-center gap-2 whitespace-nowrap">
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
<span>{locationLabel(scope, targetSource)}</span>
</span>
<SelectTrigger className="w-fit gap-1.5">
{scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
{targetSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
<span>{locationLabel(scope, targetSource)}</span>
</SelectTrigger>
<SelectContent align="start">
{SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
{option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
{option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
<span>{option.label}</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
<span className="typography-micro text-muted-foreground ml-5">{option.description}</span>
</div>
</SelectItem>
))}
@@ -177,27 +197,61 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
</Select>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button className="w-full sm:w-auto" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
className="w-full sm:w-auto"
variant="default"
disabled={isInstalling || !item.installable}
onClick={() =>
void doInstall({
source: item.repoSource,
subpath: item.repoSubpath,
scope,
targetSource,
skillDir: item.skillDir,
})
}
>
{isInstalling ? 'Installing…' : 'Install'}
</Button>
</div>
{scope === 'project' && (
<div className="flex flex-wrap items-center gap-2">
<span className="typography-ui-label text-foreground">Project</span>
{projects.length === 0 ? (
<span className="typography-meta text-muted-foreground">No projects available</span>
) : (
<Select
value={resolvedTargetProjectId ?? ''}
onValueChange={(v) => setTargetProjectId(v)}
disabled={projects.length === 1}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent align="start">
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.label || p.path}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{item.warnings?.length ? (
<div className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-2 py-1.5 rounded">
{item.warnings.join(' · ')}
</div>
) : null}
</div>
<DialogFooter>
<ButtonLarge
variant="ghost"
onClick={() => onOpenChange(false)}
>
Cancel
</ButtonLarge>
<ButtonLarge
disabled={isInstalling || !item.installable || (scope === 'project' && !directoryOverride)}
onClick={() =>
void doInstall({
source: item.repoSource,
subpath: item.repoSubpath,
scope,
targetSource,
skillDir: item.skillDir,
directoryOverride,
})
}
>
{isInstalling ? 'Installing...' : 'Install'}
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -215,6 +269,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
targetSource: baseRequest.targetSource,
skillDir: baseRequest.skillDir,
conflictDecisions: decisions,
directoryOverride: baseRequest.directoryOverride ?? null,
});
setConflictsOpen(false);
}}
@@ -1,6 +1,6 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
@@ -12,16 +12,19 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine } from '@remixicon/react';
import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine, RiSearchLine } from '@remixicon/react';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { cn } from '@/lib/utils';
import type { SkillsCatalogItem } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -36,6 +39,7 @@ type SkillsMode = 'manual' | 'external';
interface SkillsCatalogPageProps {
mode: SkillsMode;
onModeChange: (mode: SkillsMode) => void;
showModeTabs?: boolean;
}
const loadSettings = async (): Promise<DesktopSettings | null> => {
@@ -61,7 +65,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
}
};
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange }) => {
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
const {
sources,
itemsBySource,
@@ -142,234 +146,250 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="space-y-3">
<AnimatedTabs
tabs={[
{ value: 'manual', label: 'Manual' },
{ value: 'external', label: 'External' },
]}
value={mode}
onValueChange={onModeChange}
animate={false}
/>
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">Skills Catalog</h1>
<p className="typography-meta text-muted-foreground">
Browse curated repositories and install skills into your OpenCode configuration.
</p>
{/* Header */}
<div className="mb-4">
{showModeTabs && (
<div className="mb-4">
<AnimatedTabs
tabs={[
{ value: 'manual', label: 'Manual' },
{ value: 'external', label: 'External' },
]}
value={mode}
onValueChange={onModeChange}
animate={false}
/>
</div>
)}
<h2 className="typography-ui-header font-semibold text-foreground px-1">Skills Catalog</h2>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex-1 space-y-2">
<label className="typography-ui-label font-medium text-foreground">Source</label>
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<SelectTrigger className="!h-9 w-full justify-between">
<span>{selectedSource?.label || 'Select source'}</span>
</SelectTrigger>
<SelectContent align="start">
{sources.map((src) => (
<SelectItem key={src.id} value={src.id} className="pr-2 [&>span:first-child]:hidden">
{src.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Source & Search */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">Source Repository</h3>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
if (selectedSourceId) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
className="gap-2"
>
<RiRefreshLine className="h-4 w-4" />
Refresh
</Button>
{isCustomSource ? (
<Button
type="button"
variant="outline"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
className="gap-2"
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="flex flex-wrap items-center gap-2 py-1.5">
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<RiDeleteBinLine className="h-4 w-4" />
Remove
</Button>
) : null}
<Button
type="button"
variant="default"
onClick={() => setAddCatalogOpen(true)}
className="gap-2"
>
<RiAddLine className="h-4 w-4" />
Add catalog
</Button>
</div>
</div>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent align="start">
{sources.map((src) => (
<SelectItem key={src.id} value={src.id}>
{src.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills…"
className="max-w-md"
/>
<div className="typography-meta text-muted-foreground">
{isLoadingCatalog ? 'Loading…' : `${filtered.length} skill(s)`}
</div>
</div>
<ButtonSmall
variant="outline"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title="Refresh"
>
<RiRefreshLine className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
</ButtonSmall>
{lastCatalogError ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="typography-ui-label font-medium text-foreground">Catalog error</div>
<div className="typography-meta text-muted-foreground mt-1">{lastCatalogError.message}</div>
</div>
) : null}
</div>
<div className="space-y-2">
{filtered.length === 0 && !isLoadingSource ? (
<div className="py-10 text-center text-muted-foreground">
<p className="typography-body">No skills found</p>
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
</div>
) : isLoadingSource ? (
<div className="py-10 text-center text-muted-foreground">
<p className="typography-body">Loading skills</p>
</div>
) : (
<>
{filtered.map((item) => {
const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope;
return (
<div
key={`${item.sourceId}:${item.skillDir}`}
className="rounded-lg border bg-muted/10 px-3 py-2"
{isCustomSource && (
<ButtonSmall
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
title="Remove Catalog"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="typography-ui-label truncate">{item.skillName}</div>
{installed ? (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
installed ({installedScope || 'unknown'})
</span>
) : null}
{!item.installable ? (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
not installable
</span>
) : null}
</div>
{item.description ? (
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
) : (
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
)}
{item.clawdhub ? (
<div className="typography-micro text-muted-foreground mt-1 flex items-center gap-3">
{item.clawdhub.owner ? (
<span>by {item.clawdhub.owner}</span>
) : null}
<span className="flex items-center gap-1">
<RiDownloadLine className="h-3 w-3" />
{item.clawdhub.downloads?.toLocaleString() ?? 0}
</span>
{(item.clawdhub.stars ?? 0) > 0 ? (
<span className="flex items-center gap-1">
<RiStarLine className="h-3 w-3" />
{item.clawdhub.stars}
</span>
) : null}
<span>v{item.clawdhub.version}</span>
</div>
) : null}
{item.warnings?.length ? (
<div className="typography-micro text-muted-foreground mt-1">{item.warnings.join(' · ')}</div>
) : null}
</div>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</ButtonSmall>
)}
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
Install
</Button>
</div>
</div>
);
})}
{isClawdHubSource && hasMoreClawdHub ? (
<div className="flex justify-center pt-2">
<Button
type="button"
variant="outline"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore || isLoadingSource}
>
{isLoadingMore ? 'Loading…' : 'Load more'}
</Button>
<ButtonSmall
size="xs"
className="!font-normal gap-1"
onClick={() => setAddCatalogOpen(true)}
>
<RiAddLine className="h-3.5 w-3.5" /> Add Catalog
</ButtonSmall>
</div>
<div className="py-1.5">
<div className="relative">
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills..."
className="h-7 pl-8 w-full sm:w-64"
/>
</div>
) : null}
</>
)}
</div>
<span className="typography-meta text-muted-foreground mt-1 block">
{isLoadingCatalog ? 'Loading...' : `${filtered.length} skill(s) found`}
</span>
</div>
</section>
</div>
{/* Error State */}
{lastCatalogError && (
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<div className="typography-ui-label font-medium text-[var(--status-error)]">Catalog error</div>
<div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
</div>
)}
{/* Skills List */}
<div className="mb-8">
<section className="px-2 pb-2 pt-0">
{filtered.length === 0 && !isLoadingSource ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">No skills found</p>
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
</div>
) : isLoadingSource ? (
<div className="py-8 text-center text-muted-foreground">
<RiRefreshLine className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
<p className="typography-meta">Loading skills...</p>
</div>
) : (
<div className="divide-y divide-[var(--surface-subtle)]">
{filtered.map((item) => {
const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope;
return (
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="typography-ui-label font-medium text-foreground truncate">{item.skillName}</span>
{installed && (
<span className="typography-micro text-[var(--status-success)] bg-[var(--status-success)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
installed ({installedScope || 'unknown'})
</span>
)}
{!item.installable && (
<span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
not installable
</span>
)}
</div>
{item.description ? (
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
) : (
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">No description provided</div>
)}
{item.clawdhub && (
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
{item.clawdhub.owner && (
<span>by <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
)}
<span className="flex items-center gap-1">
<RiDownloadLine className="h-3 w-3" />
{item.clawdhub.downloads?.toLocaleString() ?? 0}
</span>
{(item.clawdhub.stars ?? 0) > 0 && (
<span className="flex items-center gap-1">
<RiStarLine className="h-3 w-3" />
{item.clawdhub.stars}
</span>
)}
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
</div>
)}
{item.warnings?.length ? (
<div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
{item.warnings.join(' · ')}
</div>
) : null}
</div>
<ButtonSmall
variant="outline"
size="xs"
className="!font-normal shrink-0"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
Install
</ButtonSmall>
</div>
</div>
);
})}
</div>
)}
</section>
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2 px-2">
<ButtonSmall
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore}
>
{isLoadingMore ? 'Loading...' : 'Load More Skills'}
</ButtonSmall>
</div>
)}
</div>
{/* Dialogs */}
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
<InstallSkillDialog open={installDialogOpen} onOpenChange={setInstallDialogOpen} item={installItem} />
<Dialog
open={isRemoveCatalogDialogOpen}
onOpenChange={(open) => {
if (!isRemovingCatalog) {
setIsRemoveCatalogDialogOpen(open);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Remove Catalog</DialogTitle>
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
</DialogHeader>
<DialogFooter>
<ButtonLarge
variant="ghost"
onClick={() => setIsRemoveCatalogDialogOpen(false)}
disabled={isRemovingCatalog}
>
Cancel
</ButtonLarge>
<ButtonLarge className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
Remove Catalog
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
<InstallSkillDialog open={installDialogOpen} onOpenChange={setInstallDialogOpen} item={installItem} />
<Dialog
open={isRemoveCatalogDialogOpen}
onOpenChange={(open) => {
if (!isRemovingCatalog) {
setIsRemoveCatalogDialogOpen(open);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Remove Catalog</DialogTitle>
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsRemoveCatalogDialogOpen(false)}
disabled={isRemovingCatalog}
>
Cancel
</Button>
<Button variant="destructive" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ScrollableOverlay>
);
@@ -4,7 +4,7 @@ import { formatPercent, formatWindowLabel, calculatePace, calculateExpectedUsage
import { UsageProgressBar } from './UsageProgressBar';
import { PaceIndicator } from './PaceIndicator';
import { useQuotaStore } from '@/stores/useQuotaStore';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
interface UsageCardProps {
title: string;
@@ -30,67 +30,63 @@ export const UsageCard: React.FC<UsageCardProps> = ({
const resetLabel = window.resetAfterFormatted ?? window.resetAtFormatted ?? '';
const windowLabel = formatWindowLabel(title);
// Calculate pace info for the usage window
// Pass the title (window label) to infer windowSeconds when not provided by the API
const paceInfo = React.useMemo(() => {
return calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, title);
}, [window.usedPercent, window.resetAt, window.windowSeconds, title]);
// Calculate expected marker position for weekly/monthly quotas
const expectedMarkerPercent = React.useMemo(() => {
if (!paceInfo || paceInfo.dailyAllocationPercent === null) {
return null;
}
// Show marker based on elapsed time ratio
const expectedUsed = calculateExpectedUsagePercent(paceInfo.elapsedRatio);
// If displaying remaining, invert the marker position
return displayMode === 'remaining' ? 100 - expectedUsed : expectedUsed;
}, [paceInfo, displayMode]);
return (
<div className="rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 shadow-sm">
<div className="py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="typography-ui-label text-foreground truncate">{windowLabel}</div>
{subtitle && (
<div className="typography-micro text-muted-foreground truncate">{subtitle}</div>
<div className="min-w-0 flex-1 flex items-center gap-2">
{showToggle && (
<Checkbox
checked={toggleEnabled}
onChange={(checked) => onToggle?.(checked)}
ariaLabel="Show in dropdown"
/>
)}
</div>
{showToggle ? (
<Switch
checked={toggleEnabled}
onCheckedChange={onToggle}
aria-label="Show in dropdown"
/>
) : (
<div className="typography-ui-label text-foreground tabular-nums">
{percentLabel === '-' ? '' : percentLabel}
<div className="min-w-0 flex flex-col">
<span className="typography-ui-label text-foreground truncate">{windowLabel}</span>
{subtitle && (
<span className="typography-meta text-muted-foreground truncate">{subtitle}</span>
)}
</div>
)}
</div>
<div className="typography-ui-label text-foreground tabular-nums flex items-center justify-end">
{percentLabel === '-' ? '' : percentLabel}
</div>
</div>
<div className="mt-3">
<div className="mt-2.5">
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
expectedMarkerPercent={expectedMarkerPercent}
className="h-1.5"
/>
<div className="mt-1 text-right typography-micro text-muted-foreground text-[10px]">
{barLabel}
<div className="mt-1 flex items-center justify-between">
<span className="typography-micro text-muted-foreground">
{resetLabel ? `Resets ${resetLabel}` : ''}
</span>
<span className="typography-micro text-muted-foreground">
{barLabel}
</span>
</div>
</div>
{/* Pace indicator - only shown when we have pace info */}
{paceInfo && (
<div className="mt-2">
<div className="mt-1.5">
<PaceIndicator paceInfo={paceInfo} />
</div>
)}
<div className="mt-3 flex items-center justify-between text-muted-foreground">
<span className="typography-micro">Resets</span>
<span className="typography-micro tabular-nums">{resetLabel}</span>
</div>
</div>
);
};
@@ -1,9 +1,9 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Checkbox } from '@/components/ui/checkbox';
import { UsageCard } from './UsageCard';
import { QUOTA_PROVIDERS } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { Switch } from '@/components/ui/switch';
import { updateDesktopSettings } from '@/lib/persistence';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import {
@@ -11,9 +11,10 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
import { RiArrowDownSLine, RiArrowRightSLine, RiInformationLine } from '@remixicon/react';
import type { UsageWindows, QuotaProviderId } from '@/types';
import { getAllModelFamilies, getDisplayModelName, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
const formatTime = (timestamp: number | null) => {
if (!timestamp) return '-';
@@ -83,7 +84,6 @@ export const UsagePage: React.FC = () => {
void updateDesktopSettings({ usageDropdownProviders: next });
}, [dropdownProviderIds, selectedProviderId, setDropdownProviderIds]);
// Get models for the selected provider
const providerModels = React.useMemo((): ModelInfo[] => {
if (!usage?.models) return [];
return Object.entries(usage.models)
@@ -91,14 +91,12 @@ export const UsagePage: React.FC = () => {
.filter((model) => Object.keys(model.windows.windows).length > 0);
}, [usage?.models]);
// Apply default selections on mount if no prior selections exist
React.useEffect(() => {
if (selectedProviderId && providerModels.length > 0) {
applyDefaultSelections(selectedProviderId, providerModels.map((m) => m.name));
}
}, [selectedProviderId, providerModels, applyDefaultSelections]);
// Group models by family
const modelsByFamily = React.useMemo(() => {
if (!selectedProviderId || providerModels.length === 0) {
return new Map<string | null, ModelInfo[]>();
@@ -110,16 +108,13 @@ export const UsagePage: React.FC = () => {
);
}, [providerModels, selectedProviderId]);
// Get sorted families
const sortedFamilies = React.useMemo(() => {
if (!selectedProviderId) return [];
const families = getAllModelFamilies(selectedProviderId as QuotaProviderId);
return sortModelFamilies(families);
}, [selectedProviderId]);
// Collapsible state for family sections (persist per provider)
const [collapsedFamilies, setCollapsedFamilies] = React.useState<Record<string, boolean>>(() => {
// Default: all families start expanded (not collapsed)
return {};
});
@@ -133,7 +128,6 @@ export const UsagePage: React.FC = () => {
const handleModelToggle = React.useCallback((modelName: string) => {
if (!selectedProviderId) return;
toggleModelSelected(selectedProviderId, modelName);
// Also update settings to persist
const currentSelected = selectedModels[selectedProviderId] ?? [];
const isSelected = currentSelected.includes(modelName);
const nextSelected = isSelected
@@ -143,7 +137,6 @@ export const UsagePage: React.FC = () => {
void updateDesktopSettings({ usageSelectedModels: nextSettings });
}, [selectedProviderId, selectedModels, toggleModelSelected]);
// Get selected models for this provider
const providerSelectedModels = selectedProviderId ? (selectedModels[selectedProviderId] ?? []) : [];
if (!selectedProviderId) {
@@ -156,177 +149,220 @@ export const UsagePage: React.FC = () => {
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<ProviderLogo providerId={selectedProviderId} className="h-5 w-5" />
<h1 className="typography-ui-header font-semibold text-lg">{providerName} Usage</h1>
</div>
<p className="typography-meta text-muted-foreground">
{isLoading ? 'Refreshing usage...' : `Last updated ${formatTime(lastUpdated)}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="typography-micro text-muted-foreground">Show in dropdown</span>
<Switch
checked={showInDropdown}
onCheckedChange={handleDropdownToggle}
aria-label={`Show ${providerName} in usage dropdown`}
className="data-[state=checked]:bg-[var(--status-info)]"
/>
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="mb-4 flex items-center gap-3">
<ProviderLogo providerId={selectedProviderId} className="h-5 w-5 shrink-0" />
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{providerName} Usage
</h2>
<p className="typography-meta text-muted-foreground truncate">
{isLoading ? (
<span className="animate-pulse">Refreshing usage...</span>
) : (
`Last updated: ${formatTime(lastUpdated)}`
)}
</p>
</div>
</div>
{/* Options */}
<div className="mb-8 px-2">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showInDropdown}
onClick={() => handleDropdownToggle(!showInDropdown)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleDropdownToggle(!showInDropdown);
}
}}
>
<Checkbox
checked={showInDropdown}
onChange={handleDropdownToggle}
ariaLabel="Show in header menu"
/>
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label text-foreground">Show in Header Menu</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
When enabled, this provider's usage will be visible in the quick access dropdown menu in the app header.
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{/* State Messages */}
{!selectedResult && (
<div className="rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 text-muted-foreground">
<p className="typography-body">No usage data available yet.</p>
<div className="mb-8 px-2">
<p className="typography-ui-label text-foreground">No usage data available yet.</p>
</div>
)}
{error && (
<div className="rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 text-muted-foreground">
<p className="typography-body">Failed to refresh usage data.</p>
<p className="typography-meta mt-1">{error}</p>
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<p className="typography-ui-label font-medium text-[var(--status-error)]">Failed to refresh usage data</p>
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p>
</div>
)}
{selectedResult && !selectedResult.configured && (
<div className="rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 text-muted-foreground">
<p className="typography-body">Provider is not configured yet.</p>
<p className="typography-meta mt-1">
<div className="mb-8 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] px-4 py-3">
<p className="typography-ui-label font-medium text-[var(--status-warning)]">Provider not configured</p>
<p className="typography-meta text-[var(--status-warning)]/80 mt-1">
Add credentials in the Providers tab to enable usage tracking.
</p>
</div>
)}
{/* Overall Usage Windows */}
{usage?.windows && Object.keys(usage.windows).length > 0 && (
<div className="space-y-3">
{Object.entries(usage.windows).map(([label, window]) => (
<UsageCard key={label} title={label} window={window} />
))}
<div className="mb-8">
<section className="px-2 pb-2 pt-0">
<div className="divide-y divide-[var(--surface-subtle)]">
{Object.entries(usage.windows).map(([label, window]) => (
<UsageCard key={label} title={label} window={window} />
))}
</div>
</section>
</div>
)}
{/* Models Section - Grouped by Family */}
{/* Models Section */}
{providerModels.length > 0 && (
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Models</h2>
<p className="typography-meta text-muted-foreground">
Toggle on to show in header dropdown
</p>
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">Model Quotas</h3>
</div>
{/* Predefined families (Gemini, Claude) */}
{sortedFamilies.map((family) => {
const familyModels = modelsByFamily.get(family.id) ?? [];
if (familyModels.length === 0) return null;
<div className="space-y-3">
{/* Predefined families */}
{sortedFamilies.map((family) => {
const familyModels = modelsByFamily.get(family.id) ?? [];
if (familyModels.length === 0) return null;
const isCollapsed = collapsedFamilies[family.id] ?? false;
const isCollapsed = collapsedFamilies[family.id] ?? false;
return (
<Collapsible
key={family.id}
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed(family.id)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-left group">
<div className="space-y-0.5">
<div className="typography-ui-label font-semibold text-foreground">{family.label}</div>
<p className="typography-meta text-muted-foreground">
{familyModels.length} model{familyModels.length !== 1 ? 's' : ''}
</p>
</div>
{isCollapsed ? (
<RiArrowRightSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<RiArrowDownSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-3">
{familyModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
return (
<section key={family.id} className="p-2">
<Collapsible
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed(family.id)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="flex items-center gap-1.5 text-left">
<span className="typography-ui-label font-normal text-foreground">{family.label}</span>
<span className="typography-micro text-muted-foreground">
({familyModels.length})
</span>
</div>
{isCollapsed ? (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
{familyModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
return (
<UsageCard
key={model.name}
title={label}
subtitle={getDisplayModelName(model.name)}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</CollapsibleContent>
</Collapsible>
);
})}
return (
<UsageCard
key={model.name}
title={label}
subtitle={getDisplayModelName(model.name)}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
</section>
);
})}
{/* Other family */}
{(() => {
const otherModels = modelsByFamily.get(null) ?? [];
if (otherModels.length === 0) return null;
{/* Other family */}
{(() => {
const otherModels = modelsByFamily.get(null) ?? [];
if (otherModels.length === 0) return null;
const isCollapsed = collapsedFamilies['other'] ?? false;
const isCollapsed = collapsedFamilies['other'] ?? false;
return (
<Collapsible
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed('other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-left group">
<div className="space-y-0.5">
<div className="typography-ui-label font-semibold text-foreground">Other</div>
<p className="typography-meta text-muted-foreground">
{otherModels.length} model{otherModels.length !== 1 ? 's' : ''}
</p>
</div>
{isCollapsed ? (
<RiArrowRightSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<RiArrowDownSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-3">
{otherModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
return (
<section className="p-2">
<Collapsible
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed('other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="flex items-center gap-1.5 text-left">
<span className="typography-ui-label font-normal text-foreground">Other Models</span>
<span className="typography-micro text-muted-foreground">
({otherModels.length})
</span>
</div>
{isCollapsed ? (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
{otherModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
return (
<UsageCard
key={model.name}
title={label}
subtitle={getDisplayModelName(model.name)}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</CollapsibleContent>
</Collapsible>
);
})()}
return (
<UsageCard
key={model.name}
title={label}
subtitle={getDisplayModelName(model.name)}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
</section>
);
})()}
</div>
</div>
)}
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
providerModels.length === 0 && (
<div className="rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 text-muted-foreground">
<p className="typography-body">No quota windows reported for this provider.</p>
<div className="mb-8 px-2">
<p className="typography-ui-label text-foreground">No quota windows reported</p>
<p className="typography-meta text-muted-foreground mt-1">This provider does not currently report any rate limits or usage quotas.</p>
</div>
)}
</div>
</ScrollableOverlay>
);
@@ -1,12 +1,10 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { ButtonSmall } from '@/components/ui/button-small';
import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
import { useQuotaStore } from '@/stores/useQuotaStore';
@@ -41,9 +39,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
const setUsageRefreshInterval = useQuotaStore((state) => state.setRefreshInterval);
const setUsageDisplayMode = useQuotaStore((state) => state.setDisplayMode);
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
const { isMobile } = useDeviceInfo();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
void loadUsageSettings();
@@ -79,22 +74,22 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
void persistUsageSettings({ usageDisplayMode: value });
}, [persistUsageSettings, setUsageDisplayMode]);
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const bgClass = 'bg-background';
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Usage</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {QUOTA_PROVIDERS.length}</span>
<div className="flex items-center gap-2">
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<span className="inline-flex">
<Switch
<Checkbox
checked={usageAutoRefresh}
onCheckedChange={handleUsageAutoRefreshChange}
aria-label="Toggle auto refresh"
className="data-[state=checked]:bg-[var(--status-info)]"
onChange={handleUsageAutoRefreshChange}
ariaLabel="Toggle auto refresh"
/>
</span>
</TooltipTrigger>
@@ -107,42 +102,36 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
onValueChange={handleUsageRefreshIntervalChange}
disabled={!usageAutoRefresh}
>
<SelectTrigger size="sm" className="min-w-[72px]">
<SelectTrigger className="w-fit">
<SelectValue placeholder="Interval" />
</SelectTrigger>
<SelectContent>
<SelectItem value="30000" className="pr-2 [&>span:first-child]:hidden">30s</SelectItem>
<SelectItem value="60000" className="pr-2 [&>span:first-child]:hidden">1m</SelectItem>
<SelectItem value="300000" className="pr-2 [&>span:first-child]:hidden">5m</SelectItem>
<SelectItem value="30000">30s</SelectItem>
<SelectItem value="60000">1m</SelectItem>
<SelectItem value="300000">5m</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
<ButtonSmall
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground overflow-hidden"
className="h-7 w-7 px-0 text-muted-foreground"
onClick={() => fetchAllQuotas()}
aria-label="Refresh usage"
title="Refresh usage"
disabled={isLoading}
>
<RiRefreshLine className={cn('size-4', isLoading && 'animate-spin')} />
</Button>
<RiRefreshLine className={cn('h-3.5 w-3.5', isLoading && 'animate-spin')} />
</ButtonSmall>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">Display</span>
<Select value={usageDisplayMode} onValueChange={handleUsageDisplayModeChange}>
<SelectTrigger size="sm" className="min-w-[140px]">
<SelectTrigger className="w-fit">
<SelectValue placeholder="Display mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="usage" className="pr-2 [&>span:first-child]:hidden">
Usage
</SelectItem>
<SelectItem value="remaining" className="pr-2 [&>span:first-child]:hidden">
Quota remaining
</SelectItem>
<SelectItem value="usage">Usage</SelectItem>
<SelectItem value="remaining">Quota remaining</SelectItem>
</SelectContent>
</Select>
</div>
@@ -74,7 +74,7 @@ export function GitHubIssuePickerDialog({
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
@@ -181,9 +181,9 @@ export function GitHubIssuePickerDialog({
const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => {
setSidebarSection('settings');
setSettingsPage('github');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]);
}, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
@@ -110,7 +110,7 @@ export function GitHubPullRequestPickerDialog({
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
@@ -300,9 +300,9 @@ export function GitHubPullRequestPickerDialog({
const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => {
setSidebarSection('settings');
setSettingsPage('github');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]);
}, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
@@ -21,6 +21,7 @@ import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { isDesktopLocalOriginActive, isTauriShell } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
@@ -64,6 +65,8 @@ export const SessionDialogs: React.FC = () => {
loadSessions,
getWorktreeMetadata,
} = useSessionStore();
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
const { projects, addProject, activeProjectId } = useProjectsStore();
const { requestAccess, startAccessing } = useFileSystemAccess();
@@ -194,11 +197,52 @@ export const SessionDialogs: React.FC = () => {
setDirtyWorktreePaths(new Set());
}, []);
const deleteSessionsWithoutDialog = React.useCallback(async (payload: { sessions: Session[]; dateLabel?: string }) => {
if (payload.sessions.length === 0) {
return;
}
if (payload.sessions.length === 1) {
const target = payload.sessions[0];
const success = await deleteSession(target.id);
if (success) {
toast.success('Session deleted');
} else {
toast.error('Failed to delete session');
}
return;
}
const ids = payload.sessions.map((session) => session.id);
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
const successDescription = failedIds.length > 0
? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.`
: payload.dateLabel
? `Removed all sessions from ${payload.dateLabel}.`
: undefined;
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription(successDescription),
});
}
if (failedIds.length > 0) {
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription('Please try again in a moment.'),
});
}
}, [deleteSession, deleteSessions]);
React.useEffect(() => {
return sessionEvents.onDeleteRequest((payload) => {
if (!showDeletionDialog && (payload.mode ?? 'session') === 'session') {
void deleteSessionsWithoutDialog(payload);
return;
}
openDeleteDialog(payload);
});
}, [openDeleteDialog]);
}, [openDeleteDialog, showDeletionDialog, deleteSessionsWithoutDialog]);
React.useEffect(() => {
return sessionEvents.onDirectoryRequest(() => {
@@ -634,18 +678,29 @@ export const SessionDialogs: React.FC = () => {
</div>
</div>
) : (
<>
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
{isProcessingDelete
? 'Deleting…'
: deleteDialog?.sessions.length === 1
? 'Delete session'
: 'Delete sessions'}
</Button>
</>
<div className="flex w-full items-center justify-between gap-3">
<button
type="button"
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
className="inline-flex items-center gap-1.5 typography-meta text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
aria-pressed={!showDeletionDialog}
>
{!showDeletionDialog ? <RiCheckboxLine className="size-4 text-primary" /> : <RiCheckboxBlankLine className="size-4" />}
Never ask
</button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
{isProcessingDelete
? 'Deleting…'
: deleteDialog?.sessions.length === 1
? 'Delete session'
: 'Delete sessions'}
</Button>
</div>
</div>
);
const deleteDialogTitle = isWorktreeDelete
@@ -51,6 +51,8 @@ import {
RiAddLine,
RiArrowDownSLine,
RiArrowRightSLine,
RiCheckboxBlankLine,
RiCheckboxLine,
RiCheckLine,
RiCloseLine,
RiDeleteBinLine,
@@ -194,25 +196,14 @@ const getSessionCreatedAt = (session: Session): number => {
return toFiniteNumber(session.time?.created) ?? 0;
};
const getSessionUpdatedAt = (session: Session, attentionStates?: Map<string, { lastUserMessageAt: number | null; lastStatusChangeAt: number }>): number => {
const baseUpdated = toFiniteNumber(session.time?.updated) ?? 0;
if (!attentionStates) return baseUpdated;
const attention = attentionStates.get(session.id);
if (!attention) return baseUpdated;
return Math.max(
baseUpdated,
attention.lastUserMessageAt ?? 0,
attention.lastStatusChangeAt ?? 0
);
const getSessionUpdatedAt = (session: Session): number => {
return toFiniteNumber(session.time?.updated) ?? toFiniteNumber(session.time?.created) ?? 0;
};
const compareSessionsByPinnedAndTime = (
a: Session,
b: Session,
pinnedSessionIds: Set<string>,
attentionStates?: Map<string, { lastUserMessageAt: number | null; lastStatusChangeAt: number }>
pinnedSessionIds: Set<string>
): number => {
const aPinned = pinnedSessionIds.has(a.id);
const bPinned = pinnedSessionIds.has(b.id);
@@ -224,7 +215,7 @@ const compareSessionsByPinnedAndTime = (
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
}
return getSessionUpdatedAt(b, attentionStates) - getSessionUpdatedAt(a, attentionStates);
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
};
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
@@ -883,6 +874,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
// Session Folders store
@@ -1026,8 +1019,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, []);
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates));
}, [sessions, pinnedSessionIds, sessionAttentionStates]);
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
React.useEffect(() => {
let cancelled = false;
@@ -1081,9 +1074,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
collection.push(session);
map.set(parentID, collection);
});
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates)));
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
return map;
}, [sortedSessions, pinnedSessionIds, sessionAttentionStates]);
}, [sortedSessions, pinnedSessionIds]);
React.useEffect(() => {
const directories = new Set<string>();
@@ -1314,27 +1307,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const deleteSession = useSessionStore((state) => state.deleteSession);
const deleteSessions = useSessionStore((state) => state.deleteSessions);
const handleDeleteSession = React.useCallback(
(session: Session) => {
const executeDeleteSession = React.useCallback(
async (session: Session) => {
const descendants = collectDescendants(session.id);
setDeleteSessionConfirm({ session, descendantCount: descendants.length });
},
[collectDescendants],
);
const confirmDeleteSession = React.useCallback(async () => {
if (!deleteSessionConfirm) return;
const { session } = deleteSessionConfirm;
setDeleteSessionConfirm(null);
const descendants = collectDescendants(session.id);
if (descendants.length === 0) {
const success = await deleteSession(session.id);
if (success) {
toast.success('Session deleted');
} else {
toast.error('Failed to delete session');
if (descendants.length === 0) {
const success = await deleteSession(session.id);
if (success) {
toast.success('Session deleted');
} else {
toast.error('Failed to delete session');
}
return;
}
} else {
const ids = [session.id, ...descendants.map((s) => s.id)];
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
@@ -1343,8 +1328,28 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (failedIds.length > 0) {
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
}
}
}, [deleteSessionConfirm, collectDescendants, deleteSession, deleteSessions]);
},
[collectDescendants, deleteSession, deleteSessions],
);
const handleDeleteSession = React.useCallback(
(session: Session) => {
const descendants = collectDescendants(session.id);
if (!showDeletionDialog) {
void executeDeleteSession(session);
return;
}
setDeleteSessionConfirm({ session, descendantCount: descendants.length });
},
[collectDescendants, showDeletionDialog, executeDeleteSession],
);
const confirmDeleteSession = React.useCallback(async () => {
if (!deleteSessionConfirm) return;
const { session } = deleteSessionConfirm;
setDeleteSessionConfirm(null);
await executeDeleteSession(session);
}, [deleteSessionConfirm, executeDeleteSession]);
const confirmDeleteFolder = React.useCallback(() => {
if (!deleteFolderConfirm) return;
@@ -1396,6 +1401,24 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
}, [safeStorage]);
const createFolderAndStartRename = React.useCallback(
(scopeKey: string, parentId?: string | null) => {
if (!scopeKey) {
return null;
}
if (parentId && collapsedFolderIds.has(parentId)) {
toggleFolderCollapse(parentId);
}
const newFolder = createFolder(scopeKey, 'New folder', parentId);
setRenamingFolderId(newFolder.id);
setRenameFolderDraft(newFolder.name);
return newFolder;
},
[collapsedFolderIds, toggleFolderCollapse, createFolder],
);
const buildNode = React.useCallback(
(session: Session): SessionNode => {
const children = childrenMap.get(session.id) ?? [];
@@ -1418,7 +1441,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
projectIsRepo: boolean,
) => {
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates));
const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
const childrenMap = new Map<string, Session[]>();
@@ -1431,7 +1454,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
collection.push(session);
childrenMap.set(parentID, collection);
});
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates)));
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
// Build worktree lookup map
const worktreeByPath = new Map<string, WorktreeMetadata>();
@@ -1564,7 +1587,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return groups;
},
[homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories, sessionAttentionStates]
[homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories]
);
const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
@@ -2406,9 +2429,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
const newFolder = createFolder(sessionDirectory, 'New folder');
addSessionToFolder(sessionDirectory, newFolder.id, session.id);
}}
const newFolder = createFolderAndStartRename(sessionDirectory);
if (!newFolder) {
return;
}
addSessionToFolder(sessionDirectory, newFolder.id, session.id);
}}
>
<RiAddLine className="mr-1 h-4 w-4" />
New folder...
@@ -2480,7 +2506,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
getSessionFolderId,
addSessionToFolder,
removeSessionFromFolder,
createFolder,
createFolderAndStartRename,
notifyOnSubtasks,
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
],
@@ -2504,7 +2530,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const nodes = folder.sessionIds
.map((sid) => group.sessions.find((node) => node.session.id === sid))
.filter((n): n is SessionNode => Boolean(n))
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds, sessionAttentionStates));
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds));
return { folder, nodes };
});
@@ -2561,6 +2587,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}}
onDelete={() => {
if (!folderScopeKey) return;
if (!showDeletionDialog) {
deleteFolder(folderScopeKey, folder.id);
return;
}
// Count affected sub-folders and sessions for the confirm dialog
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
const sessionCount = nodes.length;
@@ -2604,10 +2634,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id });
}}
onNewSubFolder={depth === 0 ? () => {
if (!folderScopeKey) return;
createFolder(folderScopeKey, 'New folder', folder.id);
} : undefined}
onNewSubFolder={depth === 0 ? () => {
if (!folderScopeKey) return;
createFolderAndStartRename(folderScopeKey, folder.id);
} : undefined}
/>
)}
</DroppableFolderWrapper>
@@ -2750,27 +2780,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</TooltipContent>
</Tooltip>
) : null}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`New session or folder in ${group.label}`}
>
<RiAddLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>New session or folder</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem
onClick={() => {
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
if (projectId && projectId !== activeProjectId) {
setActiveProject(projectId);
}
@@ -2780,22 +2795,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
openNewSessionDraft({ directoryOverride: group.directory });
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`New session in ${group.label}`}
>
<RiAddLine className="mr-1.5 h-4 w-4" />
New session
</DropdownMenuItem>
{folderScopeKey ? (
<DropdownMenuItem
onClick={() => {
createFolder(folderScopeKey, 'New folder');
}}
>
<RiFolderAddLine className="mr-1.5 h-4 w-4" />
New folder
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<RiAddLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>New session</p>
</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
@@ -2856,14 +2865,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
getFoldersForScope,
collapsedFolderIds,
toggleFolderCollapse,
createFolder,
createFolderAndStartRename,
renameFolder,
deleteFolder,
showDeletionDialog,
addSessionToFolder,
renamingFolderId,
renameFolderDraft,
pinnedSessionIds,
sessionAttentionStates,
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
]
);
@@ -3396,21 +3405,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
: `"${deleteSessionConfirm?.session.title || 'Untitled Session'}" will be permanently deleted.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogFooter className="w-full sm:items-center sm:justify-between">
<button
type="button"
onClick={() => setDeleteSessionConfirm(null)}
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
className="inline-flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
aria-pressed={!showDeletionDialog}
>
Cancel
</button>
<button
type="button"
onClick={() => void confirmDeleteSession()}
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
Delete
{!showDeletionDialog ? <RiCheckboxLine className="h-4 w-4 text-primary" /> : <RiCheckboxBlankLine className="h-4 w-4" />}
Never ask
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setDeleteSessionConfirm(null)}
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Cancel
</button>
<button
type="button"
onClick={() => void confirmDeleteSession()}
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
Delete
</button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -18,6 +18,8 @@ import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { SETTINGS_PAGE_METADATA, SETTINGS_GROUP_LABELS, type SettingsRuntimeContext } from '@/lib/settings/metadata';
export const CommandPalette: React.FC = () => {
const {
@@ -26,6 +28,7 @@ export const CommandPalette: React.FC = () => {
setHelpDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
setSettingsPage,
setSessionSwitcherOpen,
setTimelineDialogOpen,
toggleSidebar,
@@ -112,6 +115,34 @@ export const CommandPalette: React.FC = () => {
handleClose();
};
const handleOpenSettingsPage = (slug: string) => {
setSettingsPage(slug);
setSettingsDialogOpen(true);
handleClose();
};
const settingsRuntimeCtx = React.useMemo<SettingsRuntimeContext>(() => {
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
return { isVSCode: isVSCodeRuntime(), isWeb: isWebRuntime(), isDesktop };
}, []);
const settingsPages = React.useMemo(() => {
return SETTINGS_PAGE_METADATA
.filter((p) => p.slug !== 'home')
.filter((p) => (p.isAvailable ? p.isAvailable(settingsRuntimeCtx) : true));
}, [settingsRuntimeCtx]);
const settingsItems = React.useMemo(() => {
const groupLabel = (group: string) => (SETTINGS_GROUP_LABELS as Record<string, string>)[group] ?? group;
return settingsPages
.slice()
.sort((a, b) => {
const g = groupLabel(a.group).localeCompare(groupLabel(b.group));
if (g !== 0) return g;
return a.title.localeCompare(b.title);
});
}, [settingsPages]);
const handleToggleRightSidebar = () => {
toggleRightSidebar();
handleClose();
@@ -234,6 +265,19 @@ export const CommandPalette: React.FC = () => {
<span>Open Settings</span>
<CommandShortcut>{shortcut('open_settings')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={() => handleOpenSettingsPage('skills.catalog')}>
<RiSettings3Line className="mr-2 h-4 w-4" />
<span>Open Skills Catalog</span>
</CommandItem>
</CommandGroup>
<CommandGroup heading="Settings">
{settingsItems.map((page) => (
<CommandItem key={page.slug} onSelect={() => handleOpenSettingsPage(page.slug)}>
<RiSettings3Line className="mr-2 h-4 w-4" />
<span>{SETTINGS_GROUP_LABELS[page.group]}: {page.title}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
@@ -30,7 +30,7 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
<img
src={src}
alt={alt || `${providerId} logo`}
className={cn('dark:invert', className)}
className={cn('dark:invert object-contain', className)}
onError={handleError}
/>
);
+126 -125
View File
@@ -2,7 +2,6 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -66,6 +65,11 @@ function stripChangelogHeading(sectionRaw: string): string {
return sectionRaw.replace(/^## \[[^\]]+\] - \d{4}-\d{2}-\d{2}\s*\n?/, '').trim();
}
function processChangelogMentions(content: string): string {
// Convert @username to markdown links so they can be styled via css
return content.replace(/(^|[^a-zA-Z0-9])@([a-zA-Z0-9-]+)/g, '$1[@$2](https://github.com/$2)');
}
function compareSemverDesc(a: string, b: string): number {
const pa = a.split('.').map((v) => Number.parseInt(v, 10));
const pb = b.split('.').map((v) => Number.parseInt(v, 10));
@@ -177,11 +181,30 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
if (result.ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// Clipboard access denied
}
};
const handleOpenExternal = useCallback(async (url: string) => {
if (typeof window === 'undefined') return;
// Try Tauri backend
type TauriShell = { shell?: { open?: (url: string) => Promise<unknown> } };
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
if (tauri?.shell?.open) {
try {
await tauri.shell.open(url);
return;
} catch {
// fall through to window.open
}
}
try {
window.open(url, '_blank', 'noopener,noreferrer');
} catch {
// ignore
}
}, []);
const handleWebUpdate = useCallback(async () => {
setWebUpdateState('updating');
setWebError(null);
@@ -231,7 +254,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
return {
kind: 'raw',
title: "What's new",
content: body,
content: processChangelogMentions(body),
};
}
@@ -242,102 +265,110 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
sections: sorted.map((section) => ({
version: section.version,
dateLabel: formatIsoDateForUI(section.date),
content: stripChangelogHeading(section.raw) || body,
content: processChangelogMentions(stripChangelogHeading(section.raw) || body),
})),
};
}, [info?.body]);
return (
<Dialog open={open} onOpenChange={isWebUpdating ? undefined : onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiDownloadCloudLine className="h-5 w-5 text-primary" />
{webUpdateState === 'restarting' || webUpdateState === 'reconnecting'
? 'Updating...'
: 'Update Available'}
<DialogContent className="max-w-4xl p-5 bg-background border-[var(--interactive-border)]" showCloseButton={true}>
{/* Header Section */}
<div className="flex items-center mb-1">
<DialogTitle className="flex items-center gap-2.5">
<RiDownloadCloudLine className="h-5 w-5 text-[var(--primary-base)]" />
<span className="text-lg font-semibold text-foreground">
{webUpdateState === 'restarting' || webUpdateState === 'reconnecting'
? 'Updating OpenChamber...'
: 'Update Available'}
</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
{/* Version Diff */}
{(info?.currentVersion || info?.version) && (
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2 font-mono text-sm ml-3">
{info?.currentVersion && (
<span className="font-mono">{info.currentVersion}</span>
<span className="text-muted-foreground">{info.currentVersion}</span>
)}
{info?.currentVersion && info?.version && (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground/50"></span>
)}
{info?.version && (
<span className="font-mono text-primary">{info.version}</span>
<span className="text-[var(--primary-base)] font-medium">{info.version}</span>
)}
</div>
)}
</div>
{/* Content Body */}
<div className="space-y-2">
{/* Web update progress */}
{isWebRuntime && isWebUpdating && (
<div className="space-y-3">
<div className="rounded-lg bg-[var(--surface-elevated)]/30 p-5 border border-[var(--surface-subtle)]">
<div className="flex items-center gap-3">
<RiLoaderLine className="h-5 w-5 animate-spin text-primary" />
<div className="text-sm">
<RiLoaderLine className="h-5 w-5 animate-spin text-[var(--primary-base)]" />
<div className="typography-ui-label text-foreground">
{webUpdateState === 'updating' && 'Installing update...'}
{webUpdateState === 'restarting' && 'Server restarting...'}
{webUpdateState === 'reconnecting' && 'Waiting for server...'}
</div>
</div>
<p className="text-xs text-muted-foreground">
<p className="mt-2 text-xs text-muted-foreground">
The page will reload automatically when the update is complete.
</p>
</div>
)}
{/* Changelog Rendering */}
{changelog && !isWebUpdating && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="typography-ui-label font-medium text-foreground/90">
{changelog.title}
</div>
</div>
<div className="rounded-lg border border-[var(--surface-subtle)] bg-[var(--surface-elevated)]/20 overflow-hidden">
<ScrollableOverlay
className={cn(
'max-h-56 rounded-md border border-border/70',
'bg-background/40 p-3'
)}
className="max-h-[400px] p-0"
fillContainer={false}
>
{changelog.kind === 'raw' ? (
<SimpleMarkdownRenderer
content={changelog.content}
className="typography-markdown-body text-foreground/90 leading-relaxed pr-3 break-words"
/>
<div
className="p-4 typography-markdown-body text-foreground leading-relaxed break-words [&_a]:!text-[var(--primary-base)] [&_a]:!no-underline hover:[&_a]:!underline"
onClickCapture={(e) => {
const target = e.target as HTMLElement;
const a = target.closest('a');
if (a && a.href) {
e.preventDefault();
e.stopPropagation();
void handleOpenExternal(a.href);
}
}}
>
<SimpleMarkdownRenderer content={changelog.content} disableLinkSafety={true} />
</div>
) : (
<div className="space-y-4 pr-3">
{changelog.sections.map((section, idx) => (
<div
key={section.version}
className={cn(
idx > 0 && 'border-t border-border/40 pt-3'
)}
>
<div className="flex items-center gap-2 mb-2">
<span
className={cn(
'typography-ui-badge font-mono',
'bg-primary/10 text-primary',
'px-2 py-0.5 rounded-md'
)}
>
<div className="divide-y divide-[var(--surface-subtle)]">
{changelog.sections.map((section) => (
<div key={section.version} className="p-4 hover:bg-background/40 transition-colors">
<div className="flex items-center gap-3 mb-3">
<span className="typography-ui-label font-mono text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">
v{section.version}
</span>
<span className="typography-micro text-muted-foreground">
<span className="text-sm font-medium text-muted-foreground">
{section.dateLabel}
</span>
</div>
<SimpleMarkdownRenderer
content={section.content}
className="typography-markdown-body text-foreground/90 leading-relaxed break-words"
/>
<div
className="typography-markdown-body text-foreground leading-relaxed break-words [&_a]:!text-[var(--primary-base)] [&_a]:!no-underline hover:[&_a]:!underline"
onClickCapture={(e) => {
const target = e.target as HTMLElement;
const a = target.closest('a');
if (a && a.href) {
e.preventDefault();
e.stopPropagation();
void handleOpenExternal(a.href);
}
}}
>
<SimpleMarkdownRenderer content={section.content} disableLinkSafety={true} />
</div>
</div>
))}
</div>
@@ -346,24 +377,24 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div>
)}
{/* Web runtime: show CLI command only on error as fallback */}
{/* Web runtime fallback command */}
{isWebRuntime && webUpdateState === 'error' && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="space-y-2 mt-4">
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
<RiTerminalLine className="h-4 w-4" />
<span>Or update via terminal:</span>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm text-foreground overflow-x-auto">
<div className="flex items-center gap-2 p-1 pl-3 bg-[var(--surface-elevated)]/50 rounded-md border border-[var(--surface-subtle)]">
<code className="flex-1 font-mono text-sm text-foreground overflow-x-auto whitespace-nowrap">
{updateCommand}
</code>
<button
onClick={handleCopyCommand}
className={cn(
'flex items-center justify-center p-2 rounded-md',
'text-muted-foreground hover:text-foreground hover:bg-interactive-hover',
'flex items-center justify-center p-2 rounded',
'text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]',
'transition-colors',
copied && 'text-primary'
copied && 'text-[var(--status-success)]'
)}
title={copied ? 'Copied!' : 'Copy command'}
>
@@ -377,55 +408,48 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div>
)}
{/* Desktop runtime: show download progress */}
{/* Desktop progress bar */}
{!isWebRuntime && downloading && (
<div className="space-y-2">
<div className="space-y-2 mt-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Downloading...</span>
<span className="font-mono">{progressPercent}%</span>
<span className="text-muted-foreground">Downloading update payload...</span>
<span className="font-mono text-foreground">{progressPercent}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div className="h-1.5 bg-[var(--surface-subtle)] rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
className="h-full bg-[var(--primary-base)] transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
{/* Error display */}
{(error || webError) && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{error || webError}</p>
<div className="p-3 mt-4 bg-[var(--status-error-background)] border border-[var(--status-error-border)] rounded-lg">
<p className="text-sm text-[var(--status-error)]">{error || webError}</p>
</div>
)}
</div>
<div className="flex gap-2 pt-2">
<a
href={releaseUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md',
'text-sm text-muted-foreground',
'hover:text-foreground hover:bg-interactive-hover',
'transition-colors'
)}
>
<RiExternalLinkLine className="h-4 w-4" />
GitHub
</a>
{/* Action Footer */}
<div className="mt-4 flex items-center justify-between gap-4">
<a
href={releaseUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<RiExternalLinkLine className="h-4 w-4" />
GitHub
</a>
{/* Desktop runtime buttons */}
<div className="flex-1 flex justify-end">
{/* Desktop Buttons */}
{!isWebRuntime && !downloaded && !downloading && (
<button
onClick={onDownload}
className={cn(
'flex-1 flex items-center justify-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'
)}
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
>
<RiDownloadLine className="h-4 w-4" />
Download Update
@@ -435,12 +459,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
{!isWebRuntime && downloading && (
<button
disabled
className={cn(
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary/50 text-primary-foreground',
'cursor-not-allowed'
)}
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)]/50 text-[var(--primary-foreground)] cursor-not-allowed"
>
<RiLoaderLine className="h-4 w-4 animate-spin" />
Downloading...
@@ -450,46 +469,28 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
{!isWebRuntime && downloaded && (
<button
onClick={onRestart}
className={cn(
'flex-1 flex items-center justify-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'
)}
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--status-success)] text-white hover:opacity-90 transition-opacity"
>
<RiRestartLine className="h-4 w-4" />
Restart to Update
</button>
)}
{/* Web runtime: Update Now button */}
{/* Web Buttons */}
{isWebRuntime && !isWebUpdating && (
<button
onClick={handleWebUpdate}
className={cn(
'flex-1 flex items-center justify-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'
)}
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
>
<RiDownloadLine className="h-4 w-4" />
Update Now
</button>
)}
{/* Web runtime: updating state */}
{isWebRuntime && isWebUpdating && (
<button
disabled
className={cn(
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary/50 text-primary-foreground',
'cursor-not-allowed'
)}
className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)]/50 text-[var(--primary-foreground)] cursor-not-allowed"
>
<RiLoaderLine className="h-4 w-4 animate-spin" />
Updating...
@@ -6,13 +6,14 @@ import { type VariantProps } from "class-variance-authority"
function ButtonSmall({
className,
variant,
size = "sm",
...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
return (
<Button
variant={variant}
size="sm"
className={cn("h-6 px-2 text-xs", className)}
size={size}
className={cn(size === "sm" && "h-7 px-2", className)}
{...props}
/>
)
+1
View File
@@ -25,6 +25,7 @@ const buttonVariants = cva(
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5",
xs: "h-6 rounded-md gap-1 px-1.5 typography-micro has-[>svg]:px-1.5",
lg: "h-10 rounded-lg px-6 has-[>svg]:px-4",
icon: "size-9",
},
+240 -116
View File
@@ -1,22 +1,43 @@
import * as React from "react"
import { RiArrowDownSLine, RiArrowUpSLine } from "@remixicon/react"
import { RiAddLine, RiSubtractLine } from "@remixicon/react"
import { useDeviceInfo } from "@/lib/device"
import { cn } from "@/lib/utils"
export interface NumberInputProps
extends Omit<React.ComponentProps<"input">, "value" | "onChange" | "type"> {
value: number
value?: number
onValueChange: (value: number) => void
min?: number
max?: number
step?: number
containerClassName?: string
fallbackValue?: number
onClear?: () => void
emptyLabel?: string
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function getStepDecimals(step: number) {
if (!Number.isFinite(step)) return 0
const stepString = String(step)
if (stepString.includes("e-")) {
const [, exp] = stepString.split("e-")
return Number(exp) || 0
}
const parts = stepString.split(".")
return parts.length === 2 ? parts[1]!.length : 0
}
function normalizeToStep(value: number, step: number) {
const decimals = getStepDecimals(step)
if (decimals <= 0) return value
return Number(value.toFixed(decimals))
}
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(
{
@@ -28,164 +49,267 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
className,
containerClassName,
onBlur,
onKeyDown,
disabled,
fallbackValue,
onClear,
emptyLabel = '—',
...props
},
ref
) => {
const inputRef = React.useRef<HTMLInputElement | null>(null)
const [draft, setDraft] = React.useState(() => (value === undefined ? '' : String(value)))
const { isMobile } = useDeviceInfo()
const ignoreNextClickRef = React.useRef(false)
const swallowNextClickCleanupRef = React.useRef<(() => void) | null>(null)
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
const [draft, setDraft] = React.useState(() => String(value))
React.useEffect(() => {
if (document.activeElement !== inputRef.current) {
setDraft(String(value))
const swallowNextClick = React.useCallback(() => {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return
}
}, [value])
const focusInput = React.useCallback(() => {
inputRef.current?.focus()
swallowNextClickCleanupRef.current?.()
const handleCaptureClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(event as any).stopImmediatePropagation?.()
swallowNextClickCleanupRef.current?.()
}
const timeoutId = window.setTimeout(() => {
swallowNextClickCleanupRef.current?.()
}, 700)
const cleanup = () => {
window.clearTimeout(timeoutId)
document.removeEventListener('click', handleCaptureClick, true)
swallowNextClickCleanupRef.current = null
}
swallowNextClickCleanupRef.current = cleanup
document.addEventListener('click', handleCaptureClick, true)
}, [])
const applyValue = React.useCallback(
(nextValue: number) => {
const clamped = clamp(nextValue, min, max)
onValueChange(clamped)
setDraft(String(clamped))
React.useEffect(() => {
return () => {
swallowNextClickCleanupRef.current?.()
}
}, [])
React.useEffect(() => {
setDraft(value === undefined ? '' : String(value))
}, [value])
const baseValue = React.useMemo(() => {
if (value !== undefined) return value
if (fallbackValue !== undefined) return fallbackValue
if (Number.isFinite(min)) return min
return 0
}, [fallbackValue, min, value])
const commitValue = React.useCallback(
(rawValue: number) => {
const clamped = clamp(rawValue, min, max)
onValueChange(normalizeToStep(clamped, step))
},
[max, min, onValueChange]
[max, min, onValueChange, step]
)
const currentNumericValue = React.useCallback(() => {
const parsed = Number(draft)
if (Number.isFinite(parsed)) {
return parsed
}
return value
}, [draft, value])
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const nextDraft = event.target.value
setDraft(nextDraft)
const handleIncrement = React.useCallback(() => {
applyValue(currentNumericValue() + step)
focusInput()
}, [applyValue, currentNumericValue, focusInput, step])
const handleDecrement = React.useCallback(() => {
applyValue(currentNumericValue() - step)
focusInput()
}, [applyValue, currentNumericValue, focusInput, step])
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextDraft = event.target.value
setDraft(nextDraft)
const parsed = Number(nextDraft)
if (!Number.isFinite(parsed)) {
return
}
onValueChange(clamp(parsed, min, max))
}
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const parsed = Number(draft)
if (!Number.isFinite(parsed)) {
setDraft(String(value))
} else {
const clamped = clamp(parsed, min, max)
if (clamped !== parsed) {
onValueChange(clamped)
if (nextDraft.trim() === '') {
onClear?.()
return
}
setDraft(String(clamped))
}
onBlur?.(event)
const parsed = Number(nextDraft)
if (!Number.isFinite(parsed)) {
return
}
commitValue(parsed)
},
[commitValue, onClear]
)
const handleBlur = React.useCallback(
(event: React.FocusEvent<HTMLInputElement>) => {
if (draft.trim() === '') {
if (!onClear) {
setDraft(value === undefined ? '' : String(value))
}
onBlur?.(event)
return
}
const parsed = Number(draft)
if (!Number.isFinite(parsed)) {
setDraft(value === undefined ? '' : String(value))
} else {
const clamped = clamp(parsed, min, max)
const normalized = normalizeToStep(clamped, step)
if (normalized !== value) {
onValueChange(normalized)
}
setDraft(String(normalized))
}
onBlur?.(event)
},
[draft, max, min, onBlur, onClear, onValueChange, step, value]
)
const incrementDisabled = Boolean(disabled || baseValue >= max)
const decrementDisabled = Boolean(disabled || baseValue <= min)
const handleMobileDecrement = () => {
if (!decrementDisabled) {
commitValue(baseValue - step)
}
}
const handleKeyDownInternal = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowUp") {
event.preventDefault()
handleIncrement()
return
const handleMobileIncrement = () => {
if (!incrementDisabled) {
commitValue(baseValue + step)
}
if (event.key === "ArrowDown") {
event.preventDefault()
handleDecrement()
return
}
onKeyDown?.(event)
}
const numericValue = currentNumericValue()
const decrementDisabled = disabled || numericValue <= min
const incrementDisabled = disabled || numericValue >= max
const handleMobileTouchActivate = (handler: () => void) => (event: React.TouchEvent) => {
event.preventDefault()
event.stopPropagation()
// Touch on iOS often triggers a follow-up click; ignore it.
ignoreNextClickRef.current = true
// Also swallow the synthetic click anywhere (prevents layout-shift clicks).
swallowNextClick()
handler()
}
const handleMobileClickActivate = (handler: () => void) => (event: React.MouseEvent) => {
if (ignoreNextClickRef.current) {
ignoreNextClickRef.current = false
event.preventDefault()
event.stopPropagation()
return
}
handler()
}
if (isMobile) {
return (
<div
className={cn(
// NOTE: mobile.css enforces min-height:36px on buttons; match it to avoid clipping.
"flex h-9 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-lg border border-border bg-transparent select-none overscroll-contain",
"[-webkit-user-select:none] [-webkit-touch-callout:none]",
"disabled:pointer-events-none disabled:opacity-50",
containerClassName
)}
>
<button
type="button"
aria-label="Decrease value"
disabled={decrementDisabled}
onTouchStart={handleMobileTouchActivate(handleMobileDecrement)}
onClick={handleMobileClickActivate(handleMobileDecrement)}
className={cn(
"grid h-full min-h-0 w-9 place-items-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-none",
"text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-50",
!decrementDisabled && "active:bg-interactive-hover"
)}
>
<RiSubtractLine className="block h-4 w-4" />
</button>
<div
className={cn(
"flex h-full min-w-0 w-14 items-center justify-center bg-transparent px-1.5",
"text-center text-[16px] leading-none text-foreground [font-variant-numeric:tabular-nums]",
className
)}
aria-live="polite"
>
{value === undefined ? emptyLabel : draft}
</div>
<button
type="button"
aria-label="Increase value"
disabled={incrementDisabled}
onTouchStart={handleMobileTouchActivate(handleMobileIncrement)}
onClick={handleMobileClickActivate(handleMobileIncrement)}
className={cn(
"grid h-full min-h-0 w-9 place-items-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-none",
"text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-50",
!incrementDisabled && "active:bg-interactive-hover"
)}
>
<RiAddLine className="block h-4 w-4" />
</button>
</div>
)
}
return (
<div
className={cn(
"flex h-8 items-stretch overflow-hidden rounded-lg border border-border bg-background",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
disabled && "opacity-50",
"flex h-7 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-lg border border-border bg-transparent",
"disabled:pointer-events-none disabled:opacity-50",
containerClassName
)}
>
<button
type="button"
aria-label="Decrease value"
disabled={decrementDisabled}
onClick={() => commitValue(baseValue - step)}
className={cn(
"flex h-full w-7 items-center justify-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-manipulation",
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
)}
>
<RiSubtractLine className="block h-3.5 w-3.5" />
</button>
<input
{...props}
ref={inputRef}
type="number"
inputMode="numeric"
min={Number.isFinite(min) ? min : undefined}
max={Number.isFinite(max) ? max : undefined}
step={step}
ref={ref}
type="text"
inputMode={props.inputMode ?? 'numeric'}
value={draft}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDownInternal}
disabled={disabled}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className={cn(
"h-full w-14 bg-transparent px-1.5 text-center typography-ui-label text-foreground",
"h-full min-w-0 w-14 bg-transparent px-1.5 text-center typography-ui-label leading-none text-foreground [font-variant-numeric:tabular-nums]",
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
"border-0 outline-none",
"appearance-none outline-none [appearance:textfield] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"disabled:pointer-events-none disabled:cursor-not-allowed",
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
className
)}
/>
<div className="flex w-6 flex-col border-l border-border">
<button
type="button"
aria-label="Increase value"
disabled={incrementDisabled}
onClick={handleIncrement}
className={cn(
"flex flex-1 items-center justify-center",
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
)}
>
<RiArrowUpSLine className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="Decrease value"
disabled={decrementDisabled}
onClick={handleDecrement}
className={cn(
"flex flex-1 items-center justify-center border-t border-border",
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
)}
>
<RiArrowDownSLine className="h-3.5 w-3.5" />
</button>
</div>
<button
type="button"
aria-label="Increase value"
disabled={incrementDisabled}
onClick={() => commitValue(baseValue + step)}
className={cn(
"flex h-full w-7 items-center justify-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-manipulation",
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
)}
>
<RiAddLine className="block h-3.5 w-3.5" />
</button>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { RiRadioButtonFill, RiRadioButtonLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
interface RadioProps {
checked: boolean;
onChange: () => void;
disabled?: boolean;
ariaLabel?: string;
className?: string;
iconClassName?: string;
}
export const Radio = React.memo<RadioProps>(function Radio({
checked,
onChange,
disabled = false,
ariaLabel,
className,
iconClassName,
}) {
const handleClick = React.useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!disabled && !checked) {
onChange();
}
},
[checked, disabled, onChange]
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
if (!disabled && !checked) {
onChange();
}
}
},
[checked, disabled, onChange]
);
return (
<button
type="button"
role="radio"
onClick={handleClick}
onKeyDown={handleKeyDown}
disabled={disabled}
aria-checked={checked}
aria-label={ariaLabel}
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded',
'text-muted-foreground hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
disabled && 'cursor-not-allowed opacity-50',
className
)}
>
{checked ? (
<RiRadioButtonFill className={cn('size-4 text-primary', iconClassName)} />
) : (
<RiRadioButtonLine className={cn('size-4', iconClassName)} />
)}
</button>
);
});
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,7 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
aria-describedby={descriptionId}
className={cn(
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
'w-[90vw] max-w-[1200px] h-[85vh] max-h-[900px]',
'w-[90vw] max-w-[960px] h-[85vh] max-h-[900px]',
'rounded-xl border shadow-2xl overflow-hidden',
'bg-background'
)}
@@ -812,8 +812,13 @@ export const TerminalView: React.FC = () => {
<Button
type="button"
size="sm"
variant={activeModifier === 'ctrl' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
variant="outline"
className={cn(
"h-6 w-9 p-0",
activeModifier === 'ctrl'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: undefined
)}
onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled}
>
@@ -823,8 +828,13 @@ export const TerminalView: React.FC = () => {
<Button
type="button"
size="sm"
variant={activeModifier === 'cmd' ? 'default' : 'outline'}
className="h-6 w-9 p-0"
variant="outline"
className={cn(
"h-6 w-9 p-0",
activeModifier === 'cmd'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: undefined
)}
onClick={() => handleModifierToggle('cmd')}
disabled={quickKeysDisabled}
>
@@ -278,15 +278,15 @@ export const PullRequestSection: React.FC<{
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const { isMobile, hasTouchInput } = useDeviceInfo();
const openGitHubSettings = React.useCallback(() => {
setSidebarSection('settings');
setSettingsPage('github');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]);
}, [setSettingsDialogOpen, setSettingsPage]);
const snapshotKey = React.useMemo(() => getPullRequestSnapshotKey(directory, branch), [directory, branch]);
const initialSnapshot = React.useMemo(