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
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue