feat(settings): enhance Claude root management and UI updates
- Implemented functionality to select and manage the local Claude root folder, allowing users to specify a custom path. - Added UI components for displaying and interacting with Claude root settings, including error handling for missing directories. - Enhanced the settings view to support dynamic updates based on user selections and improved state management for pending settings. - Refactored related components to integrate the new Claude root features seamlessly, including updates to the general settings section and connection handling.
This commit is contained in:
parent
8b2dbf3bcb
commit
9915cf5a03
9 changed files with 585 additions and 393 deletions
|
|
@ -500,7 +500,10 @@ export async function analyzeSessionFileMetadata(
|
||||||
|
|
||||||
// Context consumption: track main-thread assistant input tokens
|
// Context consumption: track main-thread assistant input tokens
|
||||||
if (parsed.type === 'assistant' && !parsed.isSidechain && parsed.model !== '<synthetic>') {
|
if (parsed.type === 'assistant' && !parsed.isSidechain && parsed.model !== '<synthetic>') {
|
||||||
const inputTokens = parsed.usage?.input_tokens ?? 0;
|
const inputTokens =
|
||||||
|
(parsed.usage?.input_tokens ?? 0) +
|
||||||
|
(parsed.usage?.cache_read_input_tokens ?? 0) +
|
||||||
|
(parsed.usage?.cache_creation_input_tokens ?? 0);
|
||||||
if (inputTokens > 0) {
|
if (inputTokens > 0) {
|
||||||
if (awaitingPostCompaction && compactionPhases.length > 0) {
|
if (awaitingPostCompaction && compactionPhases.length > 0) {
|
||||||
compactionPhases[compactionPhases.length - 1].post = inputTokens;
|
compactionPhases[compactionPhases.length - 1].post = inputTokens;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* RankedInjectionList - Flat list of all context injections sorted by token size descending.
|
* RankedInjectionList - All context injections sorted by token size descending.
|
||||||
* Provides a unified view across all categories, ranked by largest token consumers.
|
* Injections are shown as grouped rows (e.g., "Tool output in Turn N").
|
||||||
|
* Tool-output rows are expandable to reveal individual tool breakdowns sorted desc.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
|
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import { formatTokens } from '../utils/formatting';
|
import { formatTokens } from '../utils/formatting';
|
||||||
import { parseTurnIndex } from '../utils/pathParsing';
|
import { parseTurnIndex } from '../utils/pathParsing';
|
||||||
|
|
||||||
import type { ContextInjection } from '@renderer/types/contextInjection';
|
import type { ContextInjection, ToolOutputInjection } from '@renderer/types/contextInjection';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Constants
|
// Constants
|
||||||
|
|
@ -69,6 +71,113 @@ function getInjectionTurnIndex(injection: ContextInjection): number {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Sub-components
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Expandable tool-output row with breakdown sorted by token count desc. */
|
||||||
|
const ToolOutputRankedItem = ({
|
||||||
|
injection,
|
||||||
|
onNavigateToTurn,
|
||||||
|
}: Readonly<{
|
||||||
|
injection: ToolOutputInjection;
|
||||||
|
onNavigateToTurn?: (turnIndex: number) => void;
|
||||||
|
}>): React.ReactElement => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const hasBreakdown = injection.toolBreakdown.length > 0;
|
||||||
|
const categoryInfo = CATEGORY_COLORS['tool-output'];
|
||||||
|
|
||||||
|
const sortedBreakdown = useMemo(
|
||||||
|
() => [...injection.toolBreakdown].sort((a, b) => b.tokenCount - a.tokenCount),
|
||||||
|
[injection.toolBreakdown]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (hasBreakdown) {
|
||||||
|
setExpanded(!expanded);
|
||||||
|
} else if (onNavigateToTurn) {
|
||||||
|
const turnIndex = getInjectionTurnIndex(injection);
|
||||||
|
if (turnIndex >= 0) onNavigateToTurn(turnIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{/* Expand chevron */}
|
||||||
|
{hasBreakdown && (
|
||||||
|
<ChevronRight
|
||||||
|
className={`size-3 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||||
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Category pill */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
|
||||||
|
style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
|
||||||
|
>
|
||||||
|
{categoryInfo.label}
|
||||||
|
</span>
|
||||||
|
{/* Description */}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||||
|
{getInjectionDescription(injection)}
|
||||||
|
</span>
|
||||||
|
{/* Token count */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-xs font-medium tabular-nums"
|
||||||
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
|
>
|
||||||
|
{formatTokens(injection.estimatedTokens)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded tool breakdown */}
|
||||||
|
{expanded && hasBreakdown && (
|
||||||
|
<div className="ml-7 space-y-0.5 pb-1">
|
||||||
|
{sortedBreakdown.map((tool, idx) => (
|
||||||
|
<button
|
||||||
|
key={`${tool.toolName}-${idx}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (onNavigateToTurn) {
|
||||||
|
onNavigateToTurn(injection.turnIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-0.5 text-left text-xs transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
|
||||||
|
style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
|
||||||
|
>
|
||||||
|
{tool.toolName}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<span
|
||||||
|
className="shrink-0 tabular-nums"
|
||||||
|
style={{ color: COLOR_TEXT_MUTED, opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{formatTokens(tool.tokenCount)}
|
||||||
|
</span>
|
||||||
|
{tool.isError && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-1 py-0.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Component
|
// Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -82,37 +191,42 @@ export const RankedInjectionList = ({
|
||||||
[injections]
|
[injections]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNavigate = (injection: ContextInjection): void => {
|
|
||||||
if (!onNavigateToTurn) return;
|
|
||||||
const turnIndex = getInjectionTurnIndex(injection);
|
|
||||||
if (turnIndex >= 0) {
|
|
||||||
onNavigateToTurn(turnIndex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-0.5">
|
||||||
{sortedInjections.map((inj) => {
|
{sortedInjections.map((inj) => {
|
||||||
|
// Tool-output: expandable row
|
||||||
|
if (inj.category === 'tool-output') {
|
||||||
|
return (
|
||||||
|
<ToolOutputRankedItem
|
||||||
|
key={inj.id}
|
||||||
|
injection={inj}
|
||||||
|
onNavigateToTurn={onNavigateToTurn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other categories: simple row
|
||||||
const categoryInfo = CATEGORY_COLORS[inj.category] ?? {
|
const categoryInfo = CATEGORY_COLORS[inj.category] ?? {
|
||||||
bg: 'rgba(161, 161, 170, 0.15)',
|
bg: 'rgba(161, 161, 170, 0.15)',
|
||||||
text: '#a1a1aa',
|
text: '#a1a1aa',
|
||||||
label: inj.category,
|
label: inj.category,
|
||||||
};
|
};
|
||||||
const description = getInjectionDescription(inj);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={inj.id}
|
key={inj.id}
|
||||||
onClick={() => handleNavigate(inj)}
|
onClick={() => {
|
||||||
|
if (onNavigateToTurn) {
|
||||||
|
const turnIndex = getInjectionTurnIndex(inj);
|
||||||
|
if (turnIndex >= 0) onNavigateToTurn(turnIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-white/5"
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-white/5"
|
||||||
>
|
>
|
||||||
{/* Category pill */}
|
{/* Category pill */}
|
||||||
<span
|
<span
|
||||||
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
|
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
|
||||||
style={{
|
style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
|
||||||
backgroundColor: categoryInfo.bg,
|
|
||||||
color: categoryInfo.text,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{categoryInfo.label}
|
{categoryInfo.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -121,7 +235,7 @@ export const RankedInjectionList = ({
|
||||||
className="min-w-0 flex-1 truncate text-xs"
|
className="min-w-0 flex-1 truncate text-xs"
|
||||||
style={{ color: COLOR_TEXT_SECONDARY }}
|
style={{ color: COLOR_TEXT_SECONDARY }}
|
||||||
>
|
>
|
||||||
{description}
|
{getInjectionDescription(inj)}
|
||||||
</span>
|
</span>
|
||||||
{/* Token count */}
|
{/* Token count */}
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
const logger = createLogger('Component:DashboardView');
|
const logger = createLogger('Component:DashboardView');
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Command, FolderGit2, FolderOpen, GitBranch, Search } from 'lucide-react';
|
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings } from 'lucide-react';
|
||||||
|
|
||||||
import type { RepositoryGroup } from '@renderer/types/data';
|
import type { RepositoryGroup } from '@renderer/types/data';
|
||||||
|
|
||||||
|
|
@ -394,6 +394,7 @@ const ProjectsGrid = ({
|
||||||
|
|
||||||
export const DashboardView = (): React.JSX.Element => {
|
export const DashboardView = (): React.JSX.Element => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const openSettingsTab = useStore((s) => s.openSettingsTab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 overflow-auto bg-surface">
|
<div className="relative flex-1 overflow-auto bg-surface">
|
||||||
|
|
@ -415,14 +416,24 @@ export const DashboardView = (): React.JSX.Element => {
|
||||||
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||||
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
|
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
|
||||||
</h2>
|
</h2>
|
||||||
{searchQuery.trim() && (
|
<div className="flex items-center gap-3">
|
||||||
|
{searchQuery.trim() && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => openSettingsTab('general')}
|
||||||
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
|
className="flex items-center gap-1.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||||
|
title="Change Claude data folder"
|
||||||
>
|
>
|
||||||
Clear search
|
<Settings className="size-3" />
|
||||||
|
Change default folder
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Projects Grid */}
|
{/* Projects Grid */}
|
||||||
|
|
|
||||||
|
|
@ -387,7 +387,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
{/* Settings gear icon (Electron only - browser can't access native settings) */}
|
{/* Settings gear icon (Electron only - browser can't access native settings) */}
|
||||||
{isElectronMode() && (
|
{isElectronMode() && (
|
||||||
<button
|
<button
|
||||||
onClick={openSettingsTab}
|
onClick={() => openSettingsTab()}
|
||||||
onMouseEnter={() => setSettingsHover(true)}
|
onMouseEnter={() => setSettingsHover(true)}
|
||||||
onMouseLeave={() => setSettingsHover(false)}
|
onMouseLeave={() => setSettingsHover(false)}
|
||||||
className="rounded-md p-2 transition-colors"
|
className="rounded-md p-2 transition-colors"
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
* Provides UI for managing notifications, display settings, and advanced options.
|
* Provides UI for managing notifications, display settings, and advanced options.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { useSettingsConfig, useSettingsHandlers } from './hooks';
|
import { useSettingsConfig, useSettingsHandlers } from './hooks';
|
||||||
|
|
@ -19,6 +20,15 @@ import { type SettingsSection, SettingsTabs } from './SettingsTabs';
|
||||||
|
|
||||||
export const SettingsView = (): React.JSX.Element | null => {
|
export const SettingsView = (): React.JSX.Element | null => {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general');
|
const [activeSection, setActiveSection] = useState<SettingsSection>('general');
|
||||||
|
const pendingSettingsSection = useStore((s) => s.pendingSettingsSection);
|
||||||
|
const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingSettingsSection) {
|
||||||
|
setActiveSection(pendingSettingsSection as SettingsSection);
|
||||||
|
clearPendingSettingsSection();
|
||||||
|
}
|
||||||
|
}, [pendingSettingsSection, clearPendingSettingsSection]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,8 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
|
||||||
import { FolderOpen, Laptop, Loader2, Monitor, RotateCcw, Server, Wifi, WifiOff } from 'lucide-react';
|
|
||||||
|
|
||||||
import { SettingRow } from '../components/SettingRow';
|
import { SettingRow } from '../components/SettingRow';
|
||||||
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||||
|
|
@ -26,7 +24,6 @@ import type {
|
||||||
SshConfigHostEntry,
|
SshConfigHostEntry,
|
||||||
SshConnectionConfig,
|
SshConnectionConfig,
|
||||||
SshConnectionProfile,
|
SshConnectionProfile,
|
||||||
WslClaudeRootCandidate,
|
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
||||||
|
|
@ -37,7 +34,6 @@ const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ConnectionSection = (): React.JSX.Element => {
|
export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const connectionMode = useStore((s) => s.connectionMode);
|
|
||||||
const connectionState = useStore((s) => s.connectionState);
|
const connectionState = useStore((s) => s.connectionState);
|
||||||
const connectedHost = useStore((s) => s.connectedHost);
|
const connectedHost = useStore((s) => s.connectedHost);
|
||||||
const connectionError = useStore((s) => s.connectionError);
|
const connectionError = useStore((s) => s.connectionError);
|
||||||
|
|
@ -48,8 +44,6 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
|
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
|
||||||
const lastSshConfig = useStore((s) => s.lastSshConfig);
|
const lastSshConfig = useStore((s) => s.lastSshConfig);
|
||||||
const loadLastConnection = useStore((s) => s.loadLastConnection);
|
const loadLastConnection = useStore((s) => s.loadLastConnection);
|
||||||
const fetchProjects = useStore((s) => s.fetchProjects);
|
|
||||||
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups);
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [host, setHost] = useState('');
|
const [host, setHost] = useState('');
|
||||||
|
|
@ -70,11 +64,6 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
|
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||||
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
|
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
|
||||||
const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false);
|
|
||||||
const [claudeRootError, setClaudeRootError] = useState<string | null>(null);
|
|
||||||
const [findingWslRoots, setFindingWslRoots] = useState(false);
|
|
||||||
const [wslCandidates, setWslCandidates] = useState<WslClaudeRootCandidate[]>([]);
|
|
||||||
const [showWslModal, setShowWslModal] = useState(false);
|
|
||||||
|
|
||||||
const loadProfiles = useCallback(async () => {
|
const loadProfiles = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -90,10 +79,8 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
try {
|
try {
|
||||||
const info = await api.config.getClaudeRootInfo();
|
const info = await api.config.getClaudeRootInfo();
|
||||||
setClaudeRootInfo(info);
|
setClaudeRootInfo(info);
|
||||||
} catch (error) {
|
} catch {
|
||||||
setClaudeRootError(
|
// ignore
|
||||||
error instanceof Error ? error.message : 'Failed to load local Claude root settings'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -197,155 +184,9 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
await disconnectSsh();
|
await disconnectSsh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetWorkspaceForRootChange = useCallback((): void => {
|
|
||||||
useStore.setState({
|
|
||||||
projects: [],
|
|
||||||
repositoryGroups: [],
|
|
||||||
openTabs: [],
|
|
||||||
activeTabId: null,
|
|
||||||
selectedTabIds: [],
|
|
||||||
paneLayout: {
|
|
||||||
panes: [
|
|
||||||
{
|
|
||||||
id: 'pane-default',
|
|
||||||
tabs: [],
|
|
||||||
activeTabId: null,
|
|
||||||
selectedTabIds: [],
|
|
||||||
widthFraction: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
focusedPaneId: 'pane-default',
|
|
||||||
},
|
|
||||||
...getFullResetState(),
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyClaudeRootPath = useCallback(
|
|
||||||
async (claudeRootPath: string | null): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setUpdatingClaudeRoot(true);
|
|
||||||
setClaudeRootError(null);
|
|
||||||
|
|
||||||
await api.config.update('general', { claudeRootPath });
|
|
||||||
await loadClaudeRootInfo();
|
|
||||||
|
|
||||||
if (connectionMode === 'local') {
|
|
||||||
resetWorkspaceForRootChange();
|
|
||||||
await Promise.all([fetchProjects(), fetchRepositoryGroups()]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setClaudeRootError(error instanceof Error ? error.message : 'Failed to update Claude root');
|
|
||||||
} finally {
|
|
||||||
setUpdatingClaudeRoot(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
connectionMode,
|
|
||||||
fetchProjects,
|
|
||||||
fetchRepositoryGroups,
|
|
||||||
loadClaudeRootInfo,
|
|
||||||
resetWorkspaceForRootChange,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectClaudeRootFolder = useCallback(async (): Promise<void> => {
|
|
||||||
setClaudeRootError(null);
|
|
||||||
|
|
||||||
const selection = await api.config.selectClaudeRootFolder();
|
|
||||||
if (!selection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selection.isClaudeDirName) {
|
|
||||||
const proceed = await confirm({
|
|
||||||
title: 'Selected folder is not .claude',
|
|
||||||
message: `This folder is named "${selection.path.split(/[\\/]/).pop() ?? selection.path}", not ".claude". Continue anyway?`,
|
|
||||||
confirmLabel: 'Use Folder',
|
|
||||||
});
|
|
||||||
if (!proceed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selection.hasProjectsDir) {
|
|
||||||
const proceed = await confirm({
|
|
||||||
title: 'No projects directory found',
|
|
||||||
message: 'This folder does not contain a "projects" directory. Continue anyway?',
|
|
||||||
confirmLabel: 'Use Folder',
|
|
||||||
});
|
|
||||||
if (!proceed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await applyClaudeRootPath(selection.path);
|
|
||||||
}, [applyClaudeRootPath]);
|
|
||||||
|
|
||||||
const handleResetClaudeRoot = useCallback(async (): Promise<void> => {
|
|
||||||
await applyClaudeRootPath(null);
|
|
||||||
}, [applyClaudeRootPath]);
|
|
||||||
|
|
||||||
const applyWslCandidate = useCallback(
|
|
||||||
async (candidate: WslClaudeRootCandidate): Promise<void> => {
|
|
||||||
if (!candidate.hasProjectsDir) {
|
|
||||||
const proceed = await confirm({
|
|
||||||
title: 'WSL path missing projects directory',
|
|
||||||
message: `"${candidate.path}" does not contain a "projects" directory. Continue anyway?`,
|
|
||||||
confirmLabel: 'Use Path',
|
|
||||||
});
|
|
||||||
if (!proceed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await applyClaudeRootPath(candidate.path);
|
|
||||||
setShowWslModal(false);
|
|
||||||
},
|
|
||||||
[applyClaudeRootPath]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUseWslForClaude = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setFindingWslRoots(true);
|
|
||||||
setClaudeRootError(null);
|
|
||||||
const candidates = await api.config.findWslClaudeRoots();
|
|
||||||
setWslCandidates(candidates);
|
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
const pickManually = await confirm({
|
|
||||||
title: 'No WSL Claude paths found',
|
|
||||||
message: 'Could not find WSL distros with Claude data automatically. Select folder manually?',
|
|
||||||
confirmLabel: 'Select Folder',
|
|
||||||
});
|
|
||||||
if (pickManually) {
|
|
||||||
await handleSelectClaudeRootFolder();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidatesWithProjects = candidates.filter((candidate) => candidate.hasProjectsDir);
|
|
||||||
if (candidatesWithProjects.length === 1) {
|
|
||||||
await applyWslCandidate(candidatesWithProjects[0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowWslModal(true);
|
|
||||||
} catch (error) {
|
|
||||||
setClaudeRootError(
|
|
||||||
error instanceof Error ? error.message : 'Failed to detect WSL Claude root paths'
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setFindingWslRoots(false);
|
|
||||||
}
|
|
||||||
}, [applyWslCandidate, handleSelectClaudeRootFolder]);
|
|
||||||
|
|
||||||
const isConnecting = connectionState === 'connecting';
|
const isConnecting = connectionState === 'connecting';
|
||||||
const isConnected = connectionState === 'connected';
|
const isConnected = connectionState === 'connected';
|
||||||
const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath);
|
|
||||||
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
|
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
|
||||||
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
|
|
||||||
const isWindowsStyleDefaultPath =
|
|
||||||
/^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
|
|
||||||
|
|
||||||
const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
|
const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
|
|
@ -356,175 +197,6 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SettingsSectionHeader title="Local Claude Root" />
|
|
||||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
||||||
Choose which local folder is treated as your Claude data root
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
label="Current Local Root"
|
|
||||||
description={isCustomClaudeRoot ? 'Using custom path' : 'Using auto-detected path'}
|
|
||||||
>
|
|
||||||
<div className="max-w-96 text-right">
|
|
||||||
<div className="truncate font-mono text-xs" style={{ color: 'var(--color-text)' }}>
|
|
||||||
{resolvedClaudeRootPath}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
|
||||||
Auto-detected: {defaultClaudeRootPath}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => void handleSelectClaudeRootFolder()}
|
|
||||||
disabled={updatingClaudeRoot}
|
|
||||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{updatingClaudeRoot ? (
|
|
||||||
<Loader2 className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<FolderOpen className="size-3" />
|
|
||||||
)}
|
|
||||||
Select Folder
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => void handleResetClaudeRoot()}
|
|
||||||
disabled={updatingClaudeRoot || !isCustomClaudeRoot}
|
|
||||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<RotateCcw className="size-3" />
|
|
||||||
Use Auto-Detect
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isWindowsStyleDefaultPath && (
|
|
||||||
<button
|
|
||||||
onClick={() => void handleUseWslForClaude()}
|
|
||||||
disabled={updatingClaudeRoot || findingWslRoots}
|
|
||||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{findingWslRoots ? (
|
|
||||||
<Loader2 className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Laptop className="size-3" />
|
|
||||||
)}
|
|
||||||
Using Linux/WSL?
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{claudeRootError && (
|
|
||||||
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
|
|
||||||
<p className="text-sm text-red-400">{claudeRootError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showWslModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
<button
|
|
||||||
className="absolute inset-0 cursor-default"
|
|
||||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
|
|
||||||
onClick={() => setShowWslModal(false)}
|
|
||||||
aria-label="Close WSL path modal"
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="relative mx-4 w-full max-w-2xl rounded-lg border p-5 shadow-xl"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-overlay)',
|
|
||||||
borderColor: 'var(--color-border-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
|
||||||
Select WSL Claude Root
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
||||||
Detected WSL distributions and Claude root candidates
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
{wslCandidates.map((candidate) => (
|
|
||||||
<div
|
|
||||||
key={`${candidate.distro}:${candidate.path}`}
|
|
||||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
|
||||||
{candidate.distro}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className="truncate font-mono text-[11px]"
|
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
|
||||||
>
|
|
||||||
{candidate.path}
|
|
||||||
</p>
|
|
||||||
{!candidate.hasProjectsDir && (
|
|
||||||
<p className="text-[11px] text-amber-400">No projects directory detected</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => void applyWslCandidate(candidate)}
|
|
||||||
className="rounded-md px-3 py-1.5 text-xs transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Use This Path
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowWslModal(false)}
|
|
||||||
className="rounded-md border px-3 py-1.5 text-xs transition-colors hover:bg-white/5"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowWslModal(false);
|
|
||||||
void handleSelectClaudeRootFolder();
|
|
||||||
}}
|
|
||||||
className="rounded-md px-3 py-1.5 text-xs transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select Folder Manually
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingsSectionHeader title="Remote Connection" />
|
<SettingsSectionHeader title="Remote Connection" />
|
||||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Connect to a remote machine to view Claude Code sessions running there
|
Connect to a remote machine to view Claude Code sessions running there
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* GeneralSection - General settings including startup, appearance, and browser access.
|
* GeneralSection - General settings including startup, appearance, browser access, and local Claude root.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Check, Copy, Loader2 } from 'lucide-react';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
||||||
|
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
||||||
|
|
||||||
import type { SafeConfig } from '../hooks/useSettingsConfig';
|
import type { SafeConfig } from '../hooks/useSettingsConfig';
|
||||||
|
import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types';
|
||||||
import type { HttpServerStatus } from '@shared/types/api';
|
import type { HttpServerStatus } from '@shared/types/api';
|
||||||
|
|
||||||
// Theme options
|
// Theme options
|
||||||
|
|
@ -39,11 +43,38 @@ export const GeneralSection = ({
|
||||||
const [serverLoading, setServerLoading] = useState(false);
|
const [serverLoading, setServerLoading] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Fetch server status on mount
|
// Claude Root state
|
||||||
|
const connectionMode = useStore((s) => s.connectionMode);
|
||||||
|
const fetchProjects = useStore((s) => s.fetchProjects);
|
||||||
|
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups);
|
||||||
|
|
||||||
|
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
|
||||||
|
const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false);
|
||||||
|
const [claudeRootError, setClaudeRootError] = useState<string | null>(null);
|
||||||
|
const [findingWslRoots, setFindingWslRoots] = useState(false);
|
||||||
|
const [wslCandidates, setWslCandidates] = useState<WslClaudeRootCandidate[]>([]);
|
||||||
|
const [showWslModal, setShowWslModal] = useState(false);
|
||||||
|
|
||||||
|
// Fetch server status and Claude root info on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.httpServer.getStatus().then(setServerStatus);
|
void api.httpServer.getStatus().then(setServerStatus);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadClaudeRootInfo = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const info = await api.config.getClaudeRootInfo();
|
||||||
|
setClaudeRootInfo(info);
|
||||||
|
} catch (error) {
|
||||||
|
setClaudeRootError(
|
||||||
|
error instanceof Error ? error.message : 'Failed to load local Claude root settings'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadClaudeRootInfo();
|
||||||
|
}, [loadClaudeRootInfo]);
|
||||||
|
|
||||||
const handleServerToggle = useCallback(async (enabled: boolean) => {
|
const handleServerToggle = useCallback(async (enabled: boolean) => {
|
||||||
setServerLoading(true);
|
setServerLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -64,6 +95,156 @@ export const GeneralSection = ({
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}, [serverUrl]);
|
}, [serverUrl]);
|
||||||
|
|
||||||
|
// Claude Root handlers
|
||||||
|
const resetWorkspaceForRootChange = useCallback((): void => {
|
||||||
|
useStore.setState({
|
||||||
|
projects: [],
|
||||||
|
repositoryGroups: [],
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
selectedTabIds: [],
|
||||||
|
paneLayout: {
|
||||||
|
panes: [
|
||||||
|
{
|
||||||
|
id: 'pane-default',
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
selectedTabIds: [],
|
||||||
|
widthFraction: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
focusedPaneId: 'pane-default',
|
||||||
|
},
|
||||||
|
...getFullResetState(),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyClaudeRootPath = useCallback(
|
||||||
|
async (claudeRootPath: string | null): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setUpdatingClaudeRoot(true);
|
||||||
|
setClaudeRootError(null);
|
||||||
|
|
||||||
|
await api.config.update('general', { claudeRootPath });
|
||||||
|
await loadClaudeRootInfo();
|
||||||
|
|
||||||
|
if (connectionMode === 'local') {
|
||||||
|
resetWorkspaceForRootChange();
|
||||||
|
await Promise.all([fetchProjects(), fetchRepositoryGroups()]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setClaudeRootError(error instanceof Error ? error.message : 'Failed to update Claude root');
|
||||||
|
} finally {
|
||||||
|
setUpdatingClaudeRoot(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
connectionMode,
|
||||||
|
fetchProjects,
|
||||||
|
fetchRepositoryGroups,
|
||||||
|
loadClaudeRootInfo,
|
||||||
|
resetWorkspaceForRootChange,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectClaudeRootFolder = useCallback(async (): Promise<void> => {
|
||||||
|
setClaudeRootError(null);
|
||||||
|
|
||||||
|
const selection = await api.config.selectClaudeRootFolder();
|
||||||
|
if (!selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selection.isClaudeDirName) {
|
||||||
|
const proceed = await confirm({
|
||||||
|
title: 'Selected folder is not .claude',
|
||||||
|
message: `This folder is named "${selection.path.split(/[\\/]/).pop() ?? selection.path}", not ".claude". Continue anyway?`,
|
||||||
|
confirmLabel: 'Use Folder',
|
||||||
|
});
|
||||||
|
if (!proceed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selection.hasProjectsDir) {
|
||||||
|
const proceed = await confirm({
|
||||||
|
title: 'No projects directory found',
|
||||||
|
message: 'This folder does not contain a "projects" directory. Continue anyway?',
|
||||||
|
confirmLabel: 'Use Folder',
|
||||||
|
});
|
||||||
|
if (!proceed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyClaudeRootPath(selection.path);
|
||||||
|
}, [applyClaudeRootPath]);
|
||||||
|
|
||||||
|
const handleResetClaudeRoot = useCallback(async (): Promise<void> => {
|
||||||
|
await applyClaudeRootPath(null);
|
||||||
|
}, [applyClaudeRootPath]);
|
||||||
|
|
||||||
|
const applyWslCandidate = useCallback(
|
||||||
|
async (candidate: WslClaudeRootCandidate): Promise<void> => {
|
||||||
|
if (!candidate.hasProjectsDir) {
|
||||||
|
const proceed = await confirm({
|
||||||
|
title: 'WSL path missing projects directory',
|
||||||
|
message: `"${candidate.path}" does not contain a "projects" directory. Continue anyway?`,
|
||||||
|
confirmLabel: 'Use Path',
|
||||||
|
});
|
||||||
|
if (!proceed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyClaudeRootPath(candidate.path);
|
||||||
|
setShowWslModal(false);
|
||||||
|
},
|
||||||
|
[applyClaudeRootPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUseWslForClaude = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setFindingWslRoots(true);
|
||||||
|
setClaudeRootError(null);
|
||||||
|
const candidates = await api.config.findWslClaudeRoots();
|
||||||
|
setWslCandidates(candidates);
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
const pickManually = await confirm({
|
||||||
|
title: 'No WSL Claude paths found',
|
||||||
|
message:
|
||||||
|
'Could not find WSL distros with Claude data automatically. Select folder manually?',
|
||||||
|
confirmLabel: 'Select Folder',
|
||||||
|
});
|
||||||
|
if (pickManually) {
|
||||||
|
await handleSelectClaudeRootFolder();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidatesWithProjects = candidates.filter((candidate) => candidate.hasProjectsDir);
|
||||||
|
if (candidatesWithProjects.length === 1) {
|
||||||
|
await applyWslCandidate(candidatesWithProjects[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowWslModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
setClaudeRootError(
|
||||||
|
error instanceof Error ? error.message : 'Failed to detect WSL Claude root paths'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setFindingWslRoots(false);
|
||||||
|
}
|
||||||
|
}, [applyWslCandidate, handleSelectClaudeRootFolder]);
|
||||||
|
|
||||||
|
const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath);
|
||||||
|
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
|
||||||
|
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
|
||||||
|
const isWindowsStyleDefaultPath =
|
||||||
|
/^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsSectionHeader title="Startup" />
|
<SettingsSectionHeader title="Startup" />
|
||||||
|
|
@ -94,6 +275,175 @@ export const GeneralSection = ({
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingsSectionHeader title="Local Claude Root" />
|
||||||
|
<p className="mb-4 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
Choose which local folder is treated as your Claude data root
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
label="Current Local Root"
|
||||||
|
description={isCustomClaudeRoot ? 'Using custom path' : 'Using auto-detected path'}
|
||||||
|
>
|
||||||
|
<div className="max-w-96 text-right">
|
||||||
|
<div className="truncate font-mono text-xs" style={{ color: 'var(--color-text)' }}>
|
||||||
|
{resolvedClaudeRootPath}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
Auto-detected: {defaultClaudeRootPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => void handleSelectClaudeRootFolder()}
|
||||||
|
disabled={updatingClaudeRoot}
|
||||||
|
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{updatingClaudeRoot ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FolderOpen className="size-3" />
|
||||||
|
)}
|
||||||
|
Select Folder
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => void handleResetClaudeRoot()}
|
||||||
|
disabled={updatingClaudeRoot || !isCustomClaudeRoot}
|
||||||
|
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
Use Auto-Detect
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isWindowsStyleDefaultPath && (
|
||||||
|
<button
|
||||||
|
onClick={() => void handleUseWslForClaude()}
|
||||||
|
disabled={updatingClaudeRoot || findingWslRoots}
|
||||||
|
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{findingWslRoots ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Laptop className="size-3" />
|
||||||
|
)}
|
||||||
|
Using Linux/WSL?
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claudeRootError && (
|
||||||
|
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
|
||||||
|
<p className="text-sm text-red-400">{claudeRootError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showWslModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 cursor-default"
|
||||||
|
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
|
||||||
|
onClick={() => setShowWslModal(false)}
|
||||||
|
aria-label="Close WSL path modal"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="relative mx-4 w-full max-w-2xl rounded-lg border p-5 shadow-xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-overlay)',
|
||||||
|
borderColor: 'var(--color-border-emphasis)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Select WSL Claude Root
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
Detected WSL distributions and Claude root candidates
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{wslCandidates.map((candidate) => (
|
||||||
|
<div
|
||||||
|
key={`${candidate.distro}:${candidate.path}`}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||||
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
||||||
|
{candidate.distro}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="truncate font-mono text-[11px]"
|
||||||
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
>
|
||||||
|
{candidate.path}
|
||||||
|
</p>
|
||||||
|
{!candidate.hasProjectsDir && (
|
||||||
|
<p className="text-[11px] text-amber-400">No projects directory detected</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void applyWslCandidate(candidate)}
|
||||||
|
className="rounded-md px-3 py-1.5 text-xs transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use This Path
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWslModal(false)}
|
||||||
|
className="rounded-md border px-3 py-1.5 text-xs transition-colors hover:bg-white/5"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowWslModal(false);
|
||||||
|
void handleSelectClaudeRootFolder();
|
||||||
|
}}
|
||||||
|
className="rounded-md px-3 py-1.5 text-xs transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select Folder Manually
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingsSectionHeader title="Browser Access" />
|
<SettingsSectionHeader title="Browser Access" />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Enable server mode"
|
label="Enable server mode"
|
||||||
|
|
|
||||||
|
|
@ -61,45 +61,66 @@ const ConsumptionBadge = ({
|
||||||
const badgeRef = useRef<HTMLSpanElement>(null);
|
const badgeRef = useRef<HTMLSpanElement>(null);
|
||||||
const isHigh = contextConsumption > 150_000;
|
const isHigh = contextConsumption > 150_000;
|
||||||
|
|
||||||
|
// Calculate popover position relative to viewport for portal rendering
|
||||||
|
const popoverPosition =
|
||||||
|
showPopover && badgeRef.current
|
||||||
|
? (() => {
|
||||||
|
const rect = badgeRef.current.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
top: rect.top - 6,
|
||||||
|
left: rect.left + rect.width / 2,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive
|
||||||
<span
|
<span
|
||||||
ref={badgeRef}
|
ref={badgeRef}
|
||||||
className="relative tabular-nums"
|
className="tabular-nums"
|
||||||
style={{ color: isHigh ? 'rgb(251, 191, 36)' : undefined }}
|
style={{ color: isHigh ? 'rgb(251, 191, 36)' : undefined }}
|
||||||
onMouseEnter={() => setShowPopover(true)}
|
onMouseEnter={() => setShowPopover(true)}
|
||||||
onMouseLeave={() => setShowPopover(false)}
|
onMouseLeave={() => setShowPopover(false)}
|
||||||
>
|
>
|
||||||
{formatTokensCompact(contextConsumption)}
|
{formatTokensCompact(contextConsumption)}
|
||||||
{showPopover && phaseBreakdown && phaseBreakdown.length > 0 && (
|
{showPopover &&
|
||||||
<div
|
popoverPosition &&
|
||||||
className="absolute bottom-full left-1/2 z-50 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-lg px-3 py-2 text-[10px] shadow-xl"
|
phaseBreakdown &&
|
||||||
style={{
|
phaseBreakdown.length > 0 &&
|
||||||
backgroundColor: 'var(--color-surface-overlay)',
|
createPortal(
|
||||||
border: '1px solid var(--color-border-emphasis)',
|
<div
|
||||||
color: 'var(--color-text-secondary)',
|
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-lg px-3 py-2 text-[10px] shadow-xl"
|
||||||
}}
|
style={{
|
||||||
>
|
top: popoverPosition.top,
|
||||||
<div className="mb-1 font-medium" style={{ color: 'var(--color-text)' }}>
|
left: popoverPosition.left,
|
||||||
Total Context: {formatTokensCompact(contextConsumption)} tokens
|
backgroundColor: 'var(--color-surface-overlay)',
|
||||||
</div>
|
border: '1px solid var(--color-border-emphasis)',
|
||||||
{phaseBreakdown.length === 1 ? (
|
color: 'var(--color-text-secondary)',
|
||||||
<div>Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}</div>
|
}}
|
||||||
) : (
|
>
|
||||||
phaseBreakdown.map((phase) => (
|
<div className="mb-1 font-medium" style={{ color: 'var(--color-text)' }}>
|
||||||
<div key={phase.phaseNumber} className="flex items-center gap-1">
|
Total Context: {formatTokensCompact(contextConsumption)} tokens
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>Phase {phase.phaseNumber}:</span>
|
</div>
|
||||||
<span className="tabular-nums">{formatTokensCompact(phase.contribution)}</span>
|
{phaseBreakdown.length === 1 ? (
|
||||||
{phase.postCompaction != null && (
|
<div>Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}</div>
|
||||||
|
) : (
|
||||||
|
phaseBreakdown.map((phase) => (
|
||||||
|
<div key={phase.phaseNumber} className="flex items-center gap-1">
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>
|
<span style={{ color: 'var(--color-text-muted)' }}>
|
||||||
(compacted → {formatTokensCompact(phase.postCompaction)})
|
Phase {phase.phaseNumber}:
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="tabular-nums">{formatTokensCompact(phase.contribution)}</span>
|
||||||
</div>
|
{phase.postCompaction != null && (
|
||||||
))
|
<span style={{ color: 'var(--color-text-muted)' }}>
|
||||||
)}
|
(compacted → {formatTokensCompact(phase.postCompaction)})
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,13 @@ export interface ConfigSlice {
|
||||||
appConfig: AppConfig | null;
|
appConfig: AppConfig | null;
|
||||||
configLoading: boolean;
|
configLoading: boolean;
|
||||||
configError: string | null;
|
configError: string | null;
|
||||||
|
pendingSettingsSection: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchConfig: () => Promise<void>;
|
fetchConfig: () => Promise<void>;
|
||||||
updateConfig: (section: string, data: Record<string, unknown>) => Promise<void>;
|
updateConfig: (section: string, data: Record<string, unknown>) => Promise<void>;
|
||||||
openSettingsTab: () => void;
|
openSettingsTab: (section?: string) => void;
|
||||||
|
clearPendingSettingsSection: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -36,6 +38,7 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
||||||
appConfig: null,
|
appConfig: null,
|
||||||
configLoading: false,
|
configLoading: false,
|
||||||
configError: null,
|
configError: null,
|
||||||
|
pendingSettingsSection: null,
|
||||||
|
|
||||||
// Fetch app configuration from main process
|
// Fetch app configuration from main process
|
||||||
fetchConfig: async () => {
|
fetchConfig: async () => {
|
||||||
|
|
@ -70,9 +73,13 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
||||||
},
|
},
|
||||||
|
|
||||||
// Open or focus the settings tab (per-pane singleton)
|
// Open or focus the settings tab (per-pane singleton)
|
||||||
openSettingsTab: () => {
|
openSettingsTab: (section?: string) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
set({ pendingSettingsSection: section });
|
||||||
|
}
|
||||||
|
|
||||||
// Check if settings tab exists in focused pane
|
// Check if settings tab exists in focused pane
|
||||||
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
|
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
|
||||||
const settingsTab = focusedPane?.tabs.find((t) => t.type === 'settings');
|
const settingsTab = focusedPane?.tabs.find((t) => t.type === 'settings');
|
||||||
|
|
@ -87,4 +94,8 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearPendingSettingsSection: () => {
|
||||||
|
set({ pendingSettingsSection: null });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue