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:
committed by
GitHub
parent
d2d39c48ac
commit
d2358c2c03
@@ -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
|
||||
|
||||
@@ -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 ·{' '}
|
||||
<code className="text-foreground">!`cmd`</code> shell output ·{' '}
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user