From 9915cf5a03ca894a6757a07e8d0f60af549f7dd4 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 16 Feb 2026 20:06:09 +0900 Subject: [PATCH] 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. --- src/main/utils/jsonl.ts | 5 +- .../components/RankedInjectionList.tsx | 154 +++++++- .../components/dashboard/DashboardView.tsx | 23 +- src/renderer/components/layout/TabBar.tsx | 2 +- .../components/settings/SettingsView.tsx | 12 +- .../settings/sections/ConnectionSection.tsx | 334 +--------------- .../settings/sections/GeneralSection.tsx | 356 +++++++++++++++++- .../components/sidebar/SessionItem.tsx | 77 ++-- src/renderer/store/slices/configSlice.ts | 15 +- 9 files changed, 585 insertions(+), 393 deletions(-) diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 9e5f7ef8..1d70be2c 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -500,7 +500,10 @@ export async function analyzeSessionFileMetadata( // Context consumption: track main-thread assistant input tokens if (parsed.type === 'assistant' && !parsed.isSidechain && parsed.model !== '') { - 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; diff --git a/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx index 14bd3d77..e7101937 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx @@ -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 ( +
+ + + {/* Expanded tool breakdown */} + {expanded && hasBreakdown && ( +
+ {sortedBreakdown.map((tool, idx) => ( + + ))} +
+ )} +
+ ); +}; + // ============================================================================= // 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 ( -
+
{sortedInjections.map((inj) => { + // Tool-output: expandable row + if (inj.category === 'tool-output') { + return ( + + ); + } + + // 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 ( + )} - )} +
{/* Projects Grid */} diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 9cf37c90..26663710 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -387,7 +387,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { {/* Settings gear icon (Electron only - browser can't access native settings) */} {isElectronMode() && ( - - - - {isWindowsStyleDefaultPath && ( - - )} - - - {claudeRootError && ( -
-

{claudeRootError}

-
- )} - - {showWslModal && ( -
- -
- ))} - - -
- - -
- - - )} -

Connect to a remote machine to view Claude Code sessions running there diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index fdd1a38d..492ef7d3 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -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(null); + const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false); + const [claudeRootError, setClaudeRootError] = useState(null); + const [findingWslRoots, setFindingWslRoots] = useState(false); + const [wslCandidates, setWslCandidates] = useState([]); + 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 => { + 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 => { + 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 => { + await applyClaudeRootPath(null); + }, [applyClaudeRootPath]); + + const applyWslCandidate = useCallback( + async (candidate: WslClaudeRootCandidate): Promise => { + 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 => { + 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 (

@@ -94,6 +275,175 @@ export const GeneralSection = ({ /> + +

+ Choose which local folder is treated as your Claude data root +

+ + +
+
+ {resolvedClaudeRootPath} +
+
+ Auto-detected: {defaultClaudeRootPath} +
+
+
+ +
+ + + + + {isWindowsStyleDefaultPath && ( + + )} +
+ + {claudeRootError && ( +
+

{claudeRootError}

+
+ )} + + {showWslModal && ( +
+ +
+ ))} +
+ +
+ + +
+ + + )} + (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 setShowPopover(true)} onMouseLeave={() => setShowPopover(false)} > {formatTokensCompact(contextConsumption)} - {showPopover && phaseBreakdown && phaseBreakdown.length > 0 && ( -
-
- Total Context: {formatTokensCompact(contextConsumption)} tokens -
- {phaseBreakdown.length === 1 ? ( -
Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}
- ) : ( - phaseBreakdown.map((phase) => ( -
- Phase {phase.phaseNumber}: - {formatTokensCompact(phase.contribution)} - {phase.postCompaction != null && ( + {showPopover && + popoverPosition && + phaseBreakdown && + phaseBreakdown.length > 0 && + createPortal( +
+
+ Total Context: {formatTokensCompact(contextConsumption)} tokens +
+ {phaseBreakdown.length === 1 ? ( +
Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}
+ ) : ( + phaseBreakdown.map((phase) => ( +
- (compacted → {formatTokensCompact(phase.postCompaction)}) + Phase {phase.phaseNumber}: - )} -
- )) - )} -
- )} + {formatTokensCompact(phase.contribution)} + {phase.postCompaction != null && ( + + (compacted → {formatTokensCompact(phase.postCompaction)}) + + )} +
+ )) + )} +
, + document.body + )}
); }; diff --git a/src/renderer/store/slices/configSlice.ts b/src/renderer/store/slices/configSlice.ts index da32ba77..0cdc5320 100644 --- a/src/renderer/store/slices/configSlice.ts +++ b/src/renderer/store/slices/configSlice.ts @@ -20,11 +20,13 @@ export interface ConfigSlice { appConfig: AppConfig | null; configLoading: boolean; configError: string | null; + pendingSettingsSection: string | null; // Actions fetchConfig: () => Promise; updateConfig: (section: string, data: Record) => Promise; - openSettingsTab: () => void; + openSettingsTab: (section?: string) => void; + clearPendingSettingsSection: () => void; } // ============================================================================= @@ -36,6 +38,7 @@ export const createConfigSlice: StateCreator = (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 = (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 = (s label: 'Settings', }); }, + + clearPendingSettingsSection: () => { + set({ pendingSettingsSection: null }); + }, });