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:
matt 2026-02-16 20:06:09 +09:00
parent 8b2dbf3bcb
commit 9915cf5a03
9 changed files with 585 additions and 393 deletions

View file

@ -500,7 +500,10 @@ export async function analyzeSessionFileMetadata(
// Context consumption: track main-thread assistant input tokens
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 (awaitingPostCompaction && compactionPhases.length > 0) {
compactionPhases[compactionPhases.length - 1].post = inputTokens;

View file

@ -1,16 +1,18 @@
/**
* RankedInjectionList - Flat list of all context injections sorted by token size descending.
* Provides a unified view across all categories, ranked by largest token consumers.
* RankedInjectionList - All context injections sorted by token size descending.
* 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 { ChevronRight } from 'lucide-react';
import { formatTokens } from '../utils/formatting';
import { parseTurnIndex } from '../utils/pathParsing';
import type { ContextInjection } from '@renderer/types/contextInjection';
import type { ContextInjection, ToolOutputInjection } from '@renderer/types/contextInjection';
// =============================================================================
// 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
// =============================================================================
@ -82,37 +191,42 @@ export const RankedInjectionList = ({
[injections]
);
const handleNavigate = (injection: ContextInjection): void => {
if (!onNavigateToTurn) return;
const turnIndex = getInjectionTurnIndex(injection);
if (turnIndex >= 0) {
onNavigateToTurn(turnIndex);
}
};
return (
<div className="space-y-1">
<div className="space-y-0.5">
{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] ?? {
bg: 'rgba(161, 161, 170, 0.15)',
text: '#a1a1aa',
label: inj.category,
};
const description = getInjectionDescription(inj);
return (
<button
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"
>
{/* Category pill */}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
style={{
backgroundColor: categoryInfo.bg,
color: categoryInfo.text,
}}
style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
>
{categoryInfo.label}
</span>
@ -121,7 +235,7 @@ export const RankedInjectionList = ({
className="min-w-0 flex-1 truncate text-xs"
style={{ color: COLOR_TEXT_SECONDARY }}
>
{description}
{getInjectionDescription(inj)}
</span>
{/* Token count */}
<span

View file

@ -16,7 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView');
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';
@ -394,6 +394,7 @@ const ProjectsGrid = ({
export const DashboardView = (): React.JSX.Element => {
const [searchQuery, setSearchQuery] = useState('');
const openSettingsTab = useStore((s) => s.openSettingsTab);
return (
<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">
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
</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
onClick={() => setSearchQuery('')}
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
onClick={() => openSettingsTab('general')}
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>
)}
</div>
</div>
{/* Projects Grid */}

View file

@ -387,7 +387,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
{/* Settings gear icon (Electron only - browser can't access native settings) */}
{isElectronMode() && (
<button
onClick={openSettingsTab}
onClick={() => openSettingsTab()}
onMouseEnter={() => setSettingsHover(true)}
onMouseLeave={() => setSettingsHover(false)}
className="rounded-md p-2 transition-colors"

View file

@ -3,8 +3,9 @@
* 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 { useSettingsConfig, useSettingsHandlers } from './hooks';
@ -19,6 +20,15 @@ import { type SettingsSection, SettingsTabs } from './SettingsTabs';
export const SettingsView = (): React.JSX.Element | null => {
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 {
config,

View file

@ -11,10 +11,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { useStore } from '@renderer/store';
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
import { FolderOpen, Laptop, Loader2, Monitor, RotateCcw, Server, Wifi, WifiOff } from 'lucide-react';
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
import { SettingRow } from '../components/SettingRow';
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
@ -26,7 +24,6 @@ import type {
SshConfigHostEntry,
SshConnectionConfig,
SshConnectionProfile,
WslClaudeRootCandidate,
} from '@shared/types';
const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
@ -37,7 +34,6 @@ const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
];
export const ConnectionSection = (): React.JSX.Element => {
const connectionMode = useStore((s) => s.connectionMode);
const connectionState = useStore((s) => s.connectionState);
const connectedHost = useStore((s) => s.connectedHost);
const connectionError = useStore((s) => s.connectionError);
@ -48,8 +44,6 @@ export const ConnectionSection = (): React.JSX.Element => {
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
const lastSshConfig = useStore((s) => s.lastSshConfig);
const loadLastConnection = useStore((s) => s.loadLastConnection);
const fetchProjects = useStore((s) => s.fetchProjects);
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups);
// Form state
const [host, setHost] = useState('');
@ -70,11 +64,6 @@ export const ConnectionSection = (): React.JSX.Element => {
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | 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 () => {
try {
@ -90,10 +79,8 @@ export const ConnectionSection = (): React.JSX.Element => {
try {
const info = await api.config.getClaudeRootInfo();
setClaudeRootInfo(info);
} catch (error) {
setClaudeRootError(
error instanceof Error ? error.message : 'Failed to load local Claude root settings'
);
} catch {
// ignore
}
}, []);
@ -197,155 +184,9 @@ export const ConnectionSection = (): React.JSX.Element => {
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 isConnected = connectionState === 'connected';
const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath);
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 inputStyle = {
@ -356,175 +197,6 @@ export const ConnectionSection = (): React.JSX.Element => {
return (
<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" />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Connect to a remote machine to view Claude Code sessions running there

View file

@ -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 { 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 type { SafeConfig } from '../hooks/useSettingsConfig';
import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types';
import type { HttpServerStatus } from '@shared/types/api';
// Theme options
@ -39,11 +43,38 @@ export const GeneralSection = ({
const [serverLoading, setServerLoading] = 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(() => {
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) => {
setServerLoading(true);
try {
@ -64,6 +95,156 @@ export const GeneralSection = ({
setTimeout(() => setCopied(false), 2000);
}, [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 (
<div>
<SettingsSectionHeader title="Startup" />
@ -94,6 +275,175 @@ export const GeneralSection = ({
/>
</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" />
<SettingRow
label="Enable server mode"

View file

@ -61,45 +61,66 @@ const ConsumptionBadge = ({
const badgeRef = useRef<HTMLSpanElement>(null);
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 (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive
<span
ref={badgeRef}
className="relative tabular-nums"
className="tabular-nums"
style={{ color: isHigh ? 'rgb(251, 191, 36)' : undefined }}
onMouseEnter={() => setShowPopover(true)}
onMouseLeave={() => setShowPopover(false)}
>
{formatTokensCompact(contextConsumption)}
{showPopover && phaseBreakdown && phaseBreakdown.length > 0 && (
<div
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"
style={{
backgroundColor: 'var(--color-surface-overlay)',
border: '1px solid var(--color-border-emphasis)',
color: 'var(--color-text-secondary)',
}}
>
<div className="mb-1 font-medium" style={{ color: 'var(--color-text)' }}>
Total Context: {formatTokensCompact(contextConsumption)} tokens
</div>
{phaseBreakdown.length === 1 ? (
<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)' }}>Phase {phase.phaseNumber}:</span>
<span className="tabular-nums">{formatTokensCompact(phase.contribution)}</span>
{phase.postCompaction != null && (
{showPopover &&
popoverPosition &&
phaseBreakdown &&
phaseBreakdown.length > 0 &&
createPortal(
<div
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,
left: popoverPosition.left,
backgroundColor: 'var(--color-surface-overlay)',
border: '1px solid var(--color-border-emphasis)',
color: 'var(--color-text-secondary)',
}}
>
<div className="mb-1 font-medium" style={{ color: 'var(--color-text)' }}>
Total Context: {formatTokensCompact(contextConsumption)} tokens
</div>
{phaseBreakdown.length === 1 ? (
<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)' }}>
(compacted {formatTokensCompact(phase.postCompaction)})
Phase {phase.phaseNumber}:
</span>
)}
</div>
))
)}
</div>
)}
<span className="tabular-nums">{formatTokensCompact(phase.contribution)}</span>
{phase.postCompaction != null && (
<span style={{ color: 'var(--color-text-muted)' }}>
(compacted {formatTokensCompact(phase.postCompaction)})
</span>
)}
</div>
))
)}
</div>,
document.body
)}
</span>
);
};

View file

@ -20,11 +20,13 @@ export interface ConfigSlice {
appConfig: AppConfig | null;
configLoading: boolean;
configError: string | null;
pendingSettingsSection: string | null;
// Actions
fetchConfig: () => 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,
configLoading: false,
configError: null,
pendingSettingsSection: null,
// Fetch app configuration from main process
fetchConfig: async () => {
@ -70,9 +73,13 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
},
// Open or focus the settings tab (per-pane singleton)
openSettingsTab: () => {
openSettingsTab: (section?: string) => {
const state = get();
if (section) {
set({ pendingSettingsSection: section });
}
// Check if settings tab exists in focused pane
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
const settingsTab = focusedPane?.tabs.find((t) => t.type === 'settings');
@ -87,4 +94,8 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
label: 'Settings',
});
},
clearPendingSettingsSection: () => {
set({ pendingSettingsSection: null });
},
});