Merge pull request #10 from matt1398/feat/token-consumption-insights
Feat/token consumption insights
This commit is contained in:
commit
93ddd565ae
22 changed files with 1186 additions and 391 deletions
|
|
@ -758,6 +758,9 @@ export class ProjectScanner {
|
|||
isOngoing: metadata.isOngoing,
|
||||
gitBranch: metadata.gitBranch ?? undefined,
|
||||
metadataLevel,
|
||||
contextConsumption: metadata.contextConsumption,
|
||||
compactionCount: metadata.compactionCount,
|
||||
phaseBreakdown: metadata.phaseBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@ export interface Project {
|
|||
*/
|
||||
export type SessionMetadataLevel = 'light' | 'deep';
|
||||
|
||||
/**
|
||||
* Per-phase token breakdown for compaction-aware context consumption.
|
||||
*/
|
||||
export interface PhaseTokenBreakdown {
|
||||
/** 1-based phase number */
|
||||
phaseNumber: number;
|
||||
/** Tokens added during this phase */
|
||||
contribution: number;
|
||||
/** Context window at peak (pre-compaction or final) */
|
||||
peakTokens: number;
|
||||
/** Tokens after compaction (undefined for the last/current phase) */
|
||||
postCompaction?: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
/** Session UUID (JSONL filename without extension) */
|
||||
id: string;
|
||||
|
|
@ -89,6 +103,12 @@ export interface Session {
|
|||
gitBranch?: string;
|
||||
/** Metadata completeness level */
|
||||
metadataLevel?: SessionMetadataLevel;
|
||||
/** Total context consumed (compaction-aware sum of all phases) */
|
||||
contextConsumption?: number;
|
||||
/** Number of compaction events */
|
||||
compactionCount?: number;
|
||||
/** Per-phase token breakdown for tooltip display */
|
||||
phaseBreakdown?: PhaseTokenBreakdown[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { extractToolCalls, extractToolResults } from './toolExtraction';
|
||||
|
||||
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
|
||||
import type { PhaseTokenBreakdown } from '../types/domain';
|
||||
|
||||
const logger = createLogger('Util:jsonl');
|
||||
|
||||
|
|
@ -300,6 +301,12 @@ export interface SessionFileMetadata {
|
|||
messageCount: number;
|
||||
isOngoing: boolean;
|
||||
gitBranch: string | null;
|
||||
/** Total context consumed (compaction-aware) */
|
||||
contextConsumption?: number;
|
||||
/** Number of compaction events */
|
||||
compactionCount?: number;
|
||||
/** Per-phase token breakdown */
|
||||
phaseBreakdown?: PhaseTokenBreakdown[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -328,6 +335,8 @@ export async function analyzeSessionFileMetadata(
|
|||
let firstUserMessage: { text: string; timestamp: string } | null = null;
|
||||
let firstCommandMessage: { text: string; timestamp: string } | null = null;
|
||||
let messageCount = 0;
|
||||
// After a UserGroup, await the first main-thread assistant message to count the AIGroup
|
||||
let awaitingAIGroup = false;
|
||||
let gitBranch: string | null = null;
|
||||
|
||||
let activityIndex = 0;
|
||||
|
|
@ -337,6 +346,13 @@ export async function analyzeSessionFileMetadata(
|
|||
// Track tool_use IDs that are shutdown responses so their tool_results are also ending events
|
||||
const shutdownToolIds = new Set<string>();
|
||||
|
||||
// Context consumption tracking
|
||||
|
||||
let lastMainAssistantInputTokens = 0;
|
||||
const compactionPhases: { pre: number; post: number }[] = [];
|
||||
|
||||
let awaitingPostCompaction = false;
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
|
|
@ -357,6 +373,15 @@ export async function analyzeSessionFileMetadata(
|
|||
|
||||
if (isParsedUserChunkMessage(parsed)) {
|
||||
messageCount++;
|
||||
awaitingAIGroup = true;
|
||||
} else if (
|
||||
awaitingAIGroup &&
|
||||
parsed.type === 'assistant' &&
|
||||
parsed.model !== '<synthetic>' &&
|
||||
!parsed.isSidechain
|
||||
) {
|
||||
messageCount++;
|
||||
awaitingAIGroup = false;
|
||||
}
|
||||
|
||||
if (!gitBranch && 'gitBranch' in entry && entry.gitBranch) {
|
||||
|
|
@ -472,6 +497,82 @@ 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) +
|
||||
(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;
|
||||
awaitingPostCompaction = false;
|
||||
}
|
||||
lastMainAssistantInputTokens = inputTokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Context consumption: detect compaction events
|
||||
if (parsed.isCompactSummary) {
|
||||
compactionPhases.push({ pre: lastMainAssistantInputTokens, post: 0 });
|
||||
awaitingPostCompaction = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute context consumption from tracked phases
|
||||
let contextConsumption: number | undefined;
|
||||
let phaseBreakdown: PhaseTokenBreakdown[] | undefined;
|
||||
|
||||
if (lastMainAssistantInputTokens > 0) {
|
||||
if (compactionPhases.length === 0) {
|
||||
// No compaction: just the final input tokens
|
||||
contextConsumption = lastMainAssistantInputTokens;
|
||||
phaseBreakdown = [
|
||||
{
|
||||
phaseNumber: 1,
|
||||
contribution: lastMainAssistantInputTokens,
|
||||
peakTokens: lastMainAssistantInputTokens,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
phaseBreakdown = [];
|
||||
let total = 0;
|
||||
|
||||
// Phase 1: tokens up to first compaction
|
||||
const phase1Contribution = compactionPhases[0].pre;
|
||||
total += phase1Contribution;
|
||||
phaseBreakdown.push({
|
||||
phaseNumber: 1,
|
||||
contribution: phase1Contribution,
|
||||
peakTokens: compactionPhases[0].pre,
|
||||
postCompaction: compactionPhases[0].post,
|
||||
});
|
||||
|
||||
// Middle phases: contribution = pre[i] - post[i-1]
|
||||
for (let i = 1; i < compactionPhases.length; i++) {
|
||||
const contribution = compactionPhases[i].pre - compactionPhases[i - 1].post;
|
||||
total += contribution;
|
||||
phaseBreakdown.push({
|
||||
phaseNumber: i + 1,
|
||||
contribution,
|
||||
peakTokens: compactionPhases[i].pre,
|
||||
postCompaction: compactionPhases[i].post,
|
||||
});
|
||||
}
|
||||
|
||||
// Last phase: final tokens - last post-compaction
|
||||
const lastPhase = compactionPhases[compactionPhases.length - 1];
|
||||
const lastContribution = lastMainAssistantInputTokens - lastPhase.post;
|
||||
total += lastContribution;
|
||||
phaseBreakdown.push({
|
||||
phaseNumber: compactionPhases.length + 1,
|
||||
contribution: lastContribution,
|
||||
peakTokens: lastMainAssistantInputTokens,
|
||||
});
|
||||
|
||||
contextConsumption = total;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -479,5 +580,8 @@ export async function analyzeSessionFileMetadata(
|
|||
messageCount,
|
||||
isOngoing: lastEndingIndex === -1 ? hasAnyOngoingActivity : hasActivityAfterLastEnding,
|
||||
gitBranch,
|
||||
contextConsumption,
|
||||
compactionCount: compactionPhases.length > 0 ? compactionPhases.length : undefined,
|
||||
phaseBreakdown,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,7 +252,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
selectSearchMatch,
|
||||
});
|
||||
|
||||
const effectiveHighlightToolUseId = controllerToolUseId ?? undefined;
|
||||
// Local tool highlight for context panel navigation (separate from controller)
|
||||
const [contextNavToolUseId, setContextNavToolUseId] = useState<string | null>(null);
|
||||
const effectiveHighlightToolUseId = controllerToolUseId ?? contextNavToolUseId ?? undefined;
|
||||
// Use blue for context panel tool navigation, otherwise use controller's color
|
||||
const effectiveHighlightColor = contextNavToolUseId ? ('blue' as const) : highlightColor;
|
||||
|
||||
// Keep search match indices aligned with this tab's rendered conversation.
|
||||
// This avoids stale/global match lists after tab switches or in-place refreshes.
|
||||
|
|
@ -396,6 +400,87 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
[conversation, ensureGroupVisible, setHighlightedGroupId]
|
||||
);
|
||||
|
||||
// Handler to navigate to a user message group (preceding the AI group at turnIndex)
|
||||
const handleNavigateToUserGroup = useCallback(
|
||||
(turnIndex: number) => {
|
||||
if (!conversation) return;
|
||||
const aiItemIndex = conversation.items.findIndex(
|
||||
(item) => item.type === 'ai' && item.group.turnIndex === turnIndex
|
||||
);
|
||||
if (aiItemIndex < 0) return;
|
||||
|
||||
// Find the user item preceding this AI group
|
||||
const prevItem = aiItemIndex > 0 ? conversation.items[aiItemIndex - 1] : null;
|
||||
if (prevItem?.type !== 'user') return;
|
||||
|
||||
const groupId = prevItem.group.id;
|
||||
const element = chatItemRefs.current.get(groupId);
|
||||
if (!element) return;
|
||||
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setHighlightedGroupId(groupId);
|
||||
setIsNavigationHighlight(true);
|
||||
if (navigationHighlightTimerRef.current) {
|
||||
clearTimeout(navigationHighlightTimerRef.current);
|
||||
}
|
||||
navigationHighlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedGroupId(null);
|
||||
setIsNavigationHighlight(false);
|
||||
navigationHighlightTimerRef.current = null;
|
||||
}, 2000);
|
||||
},
|
||||
[conversation, setHighlightedGroupId]
|
||||
);
|
||||
|
||||
// Handler to navigate to a specific tool within a turn from context panel
|
||||
const handleNavigateToTool = useCallback(
|
||||
(turnIndex: number, toolUseId: string) => {
|
||||
if (!conversation) return;
|
||||
const targetItem = conversation.items.find(
|
||||
(item) => item.type === 'ai' && item.group.turnIndex === turnIndex
|
||||
);
|
||||
if (targetItem?.type !== 'ai') return;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
const groupId = targetItem.group.id;
|
||||
await ensureGroupVisible(groupId);
|
||||
|
||||
// Set group + tool highlight immediately
|
||||
setHighlightedGroupId(groupId);
|
||||
setIsNavigationHighlight(true);
|
||||
setContextNavToolUseId(toolUseId);
|
||||
|
||||
// Wait for tool element to appear in DOM (up to 500ms)
|
||||
let toolElement: HTMLElement | undefined;
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < 500) {
|
||||
toolElement = toolItemRefs.current.get(toolUseId);
|
||||
if (toolElement) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// Scroll to tool element, or fall back to AI group
|
||||
const scrollTarget = toolElement ?? aiGroupRefs.current.get(groupId);
|
||||
if (scrollTarget) {
|
||||
scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Clear highlight after 2s
|
||||
if (navigationHighlightTimerRef.current) {
|
||||
clearTimeout(navigationHighlightTimerRef.current);
|
||||
}
|
||||
navigationHighlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedGroupId(null);
|
||||
setIsNavigationHighlight(false);
|
||||
setContextNavToolUseId(null);
|
||||
navigationHighlightTimerRef.current = null;
|
||||
}, 2000);
|
||||
};
|
||||
void run();
|
||||
},
|
||||
[conversation, ensureGroupVisible, setHighlightedGroupId]
|
||||
);
|
||||
|
||||
// Scroll to current search result when it changes
|
||||
useEffect(() => {
|
||||
const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null;
|
||||
|
|
@ -695,7 +780,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
highlightToolUseId={effectiveHighlightToolUseId}
|
||||
isSearchHighlight={isSearchHighlight}
|
||||
isNavigationHighlight={isNavigationHighlight}
|
||||
highlightColor={highlightColor}
|
||||
highlightColor={effectiveHighlightColor}
|
||||
registerChatItemRef={registerChatItemRef}
|
||||
registerAIGroupRef={registerAIGroupRefCombined}
|
||||
registerToolRef={registerToolRef}
|
||||
|
|
@ -713,7 +798,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
highlightToolUseId={effectiveHighlightToolUseId}
|
||||
isSearchHighlight={isSearchHighlight}
|
||||
isNavigationHighlight={isNavigationHighlight}
|
||||
highlightColor={highlightColor}
|
||||
highlightColor={effectiveHighlightColor}
|
||||
registerChatItemRef={registerChatItemRef}
|
||||
registerAIGroupRef={registerAIGroupRefCombined}
|
||||
registerToolRef={registerToolRef}
|
||||
|
|
@ -732,6 +817,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
onClose={() => setContextPanelVisible(false)}
|
||||
projectRoot={sessionDetail?.session?.projectPath}
|
||||
onNavigateToTurn={handleNavigateToTurn}
|
||||
onNavigateToTool={handleNavigateToTool}
|
||||
onNavigateToUserGroup={handleNavigateToUserGroup}
|
||||
totalSessionTokens={lastAiGroupTotalTokens}
|
||||
phaseInfo={sessionPhaseInfo ?? undefined}
|
||||
selectedPhase={selectedContextPhase}
|
||||
|
|
|
|||
|
|
@ -97,11 +97,10 @@ const ChatHistoryItemInner = ({
|
|||
}
|
||||
case 'ai': {
|
||||
const isHighlighted = highlightedGroupId === item.group.id;
|
||||
// Pass highlightToolUseId to ALL AI groups (when not search/navigation)
|
||||
// Pass highlightToolUseId to ALL AI groups (when not search highlight)
|
||||
// Each group will check if it contains the tool and expand accordingly
|
||||
// This fixes issues where timestamp matching might fail to find the correct group
|
||||
const toolUseIdForGroup =
|
||||
!isSearchHighlight && !isNavigationHighlight ? highlightToolUseId : undefined;
|
||||
// Allowed during navigation highlights so context panel tool deep-linking works
|
||||
const toolUseIdForGroup = !isSearchHighlight ? highlightToolUseId : undefined;
|
||||
const hl = getHighlight(
|
||||
isHighlighted,
|
||||
isSearchHighlight,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* 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.
|
||||
* Individual tools support deep-link navigation to the exact tool in chat.
|
||||
* CLAUDE.md and File items show a copy-path button.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
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, ToolOutputInjection } from '@renderer/types/contextInjection';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const CATEGORY_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||
'claude-md': { bg: 'rgba(99, 102, 241, 0.15)', text: '#818cf8', label: 'CLAUDE.md' },
|
||||
'mentioned-file': { bg: 'rgba(52, 211, 153, 0.15)', text: '#34d399', label: 'File' },
|
||||
'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' },
|
||||
'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' },
|
||||
'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' },
|
||||
'user-message': { bg: 'rgba(96, 165, 250, 0.15)', text: '#60a5fa', label: 'User' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface RankedInjectionListProps {
|
||||
injections: ContextInjection[];
|
||||
onNavigateToTurn?: (turnIndex: number) => void;
|
||||
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
|
||||
onNavigateToUserGroup?: (turnIndex: number) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function getInjectionDescription(injection: ContextInjection): string {
|
||||
switch (injection.category) {
|
||||
case 'claude-md':
|
||||
return injection.displayName || injection.path;
|
||||
case 'mentioned-file':
|
||||
return injection.displayName;
|
||||
case 'tool-output':
|
||||
return `${injection.toolCount} tool${injection.toolCount !== 1 ? 's' : ''} in Turn ${injection.turnIndex + 1}`;
|
||||
case 'thinking-text':
|
||||
return `Turn ${injection.turnIndex + 1} thinking/text`;
|
||||
case 'task-coordination':
|
||||
return `Turn ${injection.turnIndex + 1} coordination`;
|
||||
case 'user-message':
|
||||
return injection.textPreview;
|
||||
}
|
||||
}
|
||||
|
||||
function getInjectionTurnIndex(injection: ContextInjection): number {
|
||||
switch (injection.category) {
|
||||
case 'claude-md':
|
||||
return parseTurnIndex(injection.firstSeenInGroup);
|
||||
case 'mentioned-file':
|
||||
return injection.firstSeenTurnIndex;
|
||||
case 'tool-output':
|
||||
case 'thinking-text':
|
||||
case 'task-coordination':
|
||||
case 'user-message':
|
||||
return injection.turnIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get copyable path for path-based injections. */
|
||||
function getCopyablePath(injection: ContextInjection): string | null {
|
||||
if (injection.category === 'claude-md') return injection.path;
|
||||
if (injection.category === 'mentioned-file') return injection.path;
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
||||
/** Expandable tool-output row with breakdown sorted by token count desc. */
|
||||
const ToolOutputRankedItem = ({
|
||||
injection,
|
||||
onNavigateToTurn,
|
||||
onNavigateToTool,
|
||||
}: Readonly<{
|
||||
injection: ToolOutputInjection;
|
||||
onNavigateToTurn?: (turnIndex: number) => void;
|
||||
onNavigateToTool?: (turnIndex: number, toolUseId: string) => 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 (tool.toolUseId && onNavigateToTool) {
|
||||
onNavigateToTool(injection.turnIndex, tool.toolUseId);
|
||||
} else 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
|
||||
// =============================================================================
|
||||
|
||||
export const RankedInjectionList = ({
|
||||
injections,
|
||||
onNavigateToTurn,
|
||||
onNavigateToTool,
|
||||
onNavigateToUserGroup,
|
||||
}: Readonly<RankedInjectionListProps>): React.ReactElement => {
|
||||
const sortedInjections = useMemo(
|
||||
() => [...injections].sort((a, b) => b.estimatedTokens - a.estimatedTokens),
|
||||
[injections]
|
||||
);
|
||||
|
||||
return (
|
||||
<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}
|
||||
onNavigateToTool={onNavigateToTool}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryInfo = CATEGORY_COLORS[inj.category] ?? {
|
||||
bg: 'rgba(161, 161, 170, 0.15)',
|
||||
text: '#a1a1aa',
|
||||
label: inj.category,
|
||||
};
|
||||
const copyPath = getCopyablePath(inj);
|
||||
|
||||
const handleClick = (): void => {
|
||||
const turnIndex = getInjectionTurnIndex(inj);
|
||||
if (turnIndex < 0) return;
|
||||
// User messages → navigate to user group; others → navigate to AI group
|
||||
if (inj.category === 'user-message' && onNavigateToUserGroup) {
|
||||
onNavigateToUserGroup(turnIndex);
|
||||
} else if (onNavigateToTurn) {
|
||||
onNavigateToTurn(turnIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={inj.id} className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex min-w-0 flex-1 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 }}
|
||||
>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
{/* Description */}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs"
|
||||
style={{ color: COLOR_TEXT_SECONDARY }}
|
||||
>
|
||||
{getInjectionDescription(inj)}
|
||||
</span>
|
||||
{/* Token count */}
|
||||
<span
|
||||
className="shrink-0 text-xs font-medium tabular-nums"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{formatTokens(inj.estimatedTokens)}
|
||||
</span>
|
||||
</button>
|
||||
{/* Copy path button for CLAUDE.md and File items */}
|
||||
{copyPath && (
|
||||
<span className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<CopyButton text={copyPath} inline />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -12,12 +12,13 @@ import {
|
|||
COLOR_TEXT_MUTED,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { FileText, X } from 'lucide-react';
|
||||
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
||||
|
||||
import { formatTokens } from '../utils/formatting';
|
||||
|
||||
import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
||||
|
||||
import type { ContextViewMode } from '../types';
|
||||
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||
|
||||
interface SessionContextHeaderProps {
|
||||
|
|
@ -28,6 +29,8 @@ interface SessionContextHeaderProps {
|
|||
phaseInfo?: ContextPhaseInfo;
|
||||
selectedPhase: number | null;
|
||||
onPhaseChange: (phase: number | null) => void;
|
||||
viewMode: ContextViewMode;
|
||||
onViewModeChange: (mode: ContextViewMode) => void;
|
||||
}
|
||||
|
||||
export const SessionContextHeader = ({
|
||||
|
|
@ -38,6 +41,8 @@ export const SessionContextHeader = ({
|
|||
phaseInfo,
|
||||
selectedPhase,
|
||||
onPhaseChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: Readonly<SessionContextHeaderProps>): React.ReactElement => {
|
||||
return (
|
||||
<div className="shrink-0 px-4 py-3" style={{ borderBottom: `1px solid ${COLOR_BORDER}` }}>
|
||||
|
|
@ -150,6 +155,40 @@ export const SessionContextHeader = ({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div
|
||||
className="mt-2 flex items-center gap-1 pt-2"
|
||||
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
|
||||
>
|
||||
<span className="mr-1 text-[10px]" style={{ color: COLOR_TEXT_MUTED }}>
|
||||
View:
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onViewModeChange('category')}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
viewMode === 'category' ? 'rgba(99, 102, 241, 0.2)' : COLOR_SURFACE_OVERLAY,
|
||||
color: viewMode === 'category' ? '#818cf8' : COLOR_TEXT_MUTED,
|
||||
}}
|
||||
>
|
||||
<LayoutList size={10} />
|
||||
Category
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('ranked')}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
viewMode === 'ranked' ? 'rgba(99, 102, 241, 0.2)' : COLOR_SURFACE_OVERLAY,
|
||||
color: viewMode === 'ranked' ? '#818cf8' : COLOR_TEXT_MUTED,
|
||||
}}
|
||||
>
|
||||
<ArrowDownWideNarrow size={10} />
|
||||
By Size
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { COLOR_BORDER, COLOR_SURFACE, COLOR_TEXT_MUTED } from '@renderer/constan
|
|||
|
||||
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
|
||||
import { MentionedFilesSection } from './components/MentionedFilesSection';
|
||||
import { RankedInjectionList } from './components/RankedInjectionList';
|
||||
import { SessionContextHeader } from './components/SessionContextHeader';
|
||||
import { TaskCoordinationSection } from './components/TaskCoordinationSection';
|
||||
import { ThinkingTextSection } from './components/ThinkingTextSection';
|
||||
|
|
@ -23,7 +24,7 @@ import {
|
|||
SECTION_USER_MESSAGES,
|
||||
} from './types';
|
||||
|
||||
import type { SectionType, SessionContextPanelProps } from './types';
|
||||
import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types';
|
||||
import type {
|
||||
ClaudeMdContextInjection,
|
||||
MentionedFileInjection,
|
||||
|
|
@ -38,11 +39,16 @@ export const SessionContextPanel = ({
|
|||
onClose,
|
||||
projectRoot,
|
||||
onNavigateToTurn,
|
||||
onNavigateToTool,
|
||||
onNavigateToUserGroup,
|
||||
totalSessionTokens,
|
||||
phaseInfo,
|
||||
selectedPhase,
|
||||
onPhaseChange,
|
||||
}: Readonly<SessionContextPanelProps>): React.ReactElement => {
|
||||
// View mode: category sections or flat ranked list
|
||||
const [viewMode, setViewMode] = useState<ContextViewMode>('category');
|
||||
|
||||
// Track which main sections are expanded
|
||||
const [expandedSections, setExpandedSections] = useState<Set<SectionType>>(
|
||||
new Set([
|
||||
|
|
@ -180,6 +186,8 @@ export const SessionContextPanel = ({
|
|||
phaseInfo={phaseInfo}
|
||||
selectedPhase={selectedPhase}
|
||||
onPhaseChange={onPhaseChange}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
|
|
@ -191,7 +199,7 @@ export const SessionContextPanel = ({
|
|||
>
|
||||
No context injections detected in this session
|
||||
</div>
|
||||
) : (
|
||||
) : viewMode === 'category' ? (
|
||||
<>
|
||||
<UserMessagesSection
|
||||
injections={userMessageInjections}
|
||||
|
|
@ -243,6 +251,13 @@ export const SessionContextPanel = ({
|
|||
onNavigateToTurn={onNavigateToTurn}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<RankedInjectionList
|
||||
injections={injections}
|
||||
onNavigateToTurn={onNavigateToTurn}
|
||||
onNavigateToTool={onNavigateToTool}
|
||||
onNavigateToUserGroup={onNavigateToUserGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export interface SessionContextPanelProps {
|
|||
projectRoot?: string;
|
||||
/** Click Turn N to navigate to that turn */
|
||||
onNavigateToTurn?: (turnIndex: number) => void;
|
||||
/** Navigate to a specific tool within a turn by toolUseId */
|
||||
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
|
||||
/** Navigate to the user message group preceding the AI group at turnIndex */
|
||||
onNavigateToUserGroup?: (turnIndex: number) => void;
|
||||
/** Total session tokens (input + output + cache) for comparison */
|
||||
totalSessionTokens?: number;
|
||||
/** Phase information for phase selector */
|
||||
|
|
@ -49,6 +53,9 @@ export type SectionType =
|
|||
| typeof SECTION_TASK_COORDINATION
|
||||
| typeof SECTION_USER_MESSAGES;
|
||||
|
||||
/** View mode for the context panel */
|
||||
export type ContextViewMode = 'category' | 'ranked';
|
||||
|
||||
// =============================================================================
|
||||
// CLAUDE.md Group Types
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { useSettingsConfig, useSettingsHandlers } from './hooks';
|
||||
|
|
@ -19,6 +20,18 @@ 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);
|
||||
|
||||
// Consume pending section during render (React-recommended pattern for adjusting state on prop change)
|
||||
const [prevPending, setPrevPending] = useState<string | null>(null);
|
||||
if (pendingSettingsSection !== prevPending) {
|
||||
setPrevPending(pendingSettingsSection);
|
||||
if (pendingSettingsSection) {
|
||||
setActiveSection(pendingSettingsSection as SettingsSection);
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
* Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
|
|
@ -12,7 +13,7 @@ import {
|
|||
separatePinnedSessions,
|
||||
} from '@renderer/utils/dateGrouping';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react';
|
||||
import { ArrowDownWideNarrow, Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { SessionItem } from './SessionItem';
|
||||
|
|
@ -47,9 +48,10 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
sessionsError,
|
||||
sessionsHasMore,
|
||||
sessionsLoadingMore,
|
||||
sessionsTotalCount,
|
||||
fetchSessionsMore,
|
||||
pinnedSessionIds,
|
||||
sessionSortMode,
|
||||
setSessionSortMode,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
sessions: s.sessions,
|
||||
|
|
@ -59,13 +61,16 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
sessionsError: s.sessionsError,
|
||||
sessionsHasMore: s.sessionsHasMore,
|
||||
sessionsLoadingMore: s.sessionsLoadingMore,
|
||||
sessionsTotalCount: s.sessionsTotalCount,
|
||||
fetchSessionsMore: s.fetchSessionsMore,
|
||||
pinnedSessionIds: s.pinnedSessionIds,
|
||||
sessionSortMode: s.sessionSortMode,
|
||||
setSessionSortMode: s.setSessionSortMode,
|
||||
}))
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const countRef = useRef<HTMLSpanElement>(null);
|
||||
const [showCountTooltip, setShowCountTooltip] = useState(false);
|
||||
|
||||
// Separate pinned sessions from unpinned
|
||||
const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo(
|
||||
|
|
@ -82,43 +87,59 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
[groupedSessions]
|
||||
);
|
||||
|
||||
// Sessions sorted by context consumption (for most-context sort mode)
|
||||
const contextSortedSessions = useMemo(() => {
|
||||
if (sessionSortMode !== 'most-context') return [];
|
||||
return [...sessions].sort((a, b) => (b.contextConsumption ?? 0) - (a.contextConsumption ?? 0));
|
||||
}, [sessions, sessionSortMode]);
|
||||
|
||||
// Flatten sessions with date headers into virtual list items
|
||||
const virtualItems = useMemo((): VirtualItem[] => {
|
||||
const items: VirtualItem[] = [];
|
||||
|
||||
// Add pinned section first
|
||||
if (pinnedSessions.length > 0) {
|
||||
items.push({
|
||||
type: 'pinned-header',
|
||||
id: 'header-pinned',
|
||||
});
|
||||
|
||||
for (const session of pinnedSessions) {
|
||||
if (sessionSortMode === 'most-context') {
|
||||
// Flat list sorted by consumption - no date headers, no pinned section
|
||||
for (const session of contextSortedSessions) {
|
||||
items.push({
|
||||
type: 'session',
|
||||
session,
|
||||
isPinned: true,
|
||||
isPinned: pinnedSessionIds.includes(session.id),
|
||||
id: `session-${session.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const category of nonEmptyCategories) {
|
||||
// Add header item
|
||||
items.push({
|
||||
type: 'header',
|
||||
category,
|
||||
id: `header-${category}`,
|
||||
});
|
||||
|
||||
// Add session items
|
||||
for (const session of groupedSessions[category]) {
|
||||
} else {
|
||||
// Default: date-grouped view with pinned section
|
||||
if (pinnedSessions.length > 0) {
|
||||
items.push({
|
||||
type: 'session',
|
||||
session,
|
||||
isPinned: false,
|
||||
id: `session-${session.id}`,
|
||||
type: 'pinned-header',
|
||||
id: 'header-pinned',
|
||||
});
|
||||
|
||||
for (const session of pinnedSessions) {
|
||||
items.push({
|
||||
type: 'session',
|
||||
session,
|
||||
isPinned: true,
|
||||
id: `session-${session.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const category of nonEmptyCategories) {
|
||||
items.push({
|
||||
type: 'header',
|
||||
category,
|
||||
id: `header-${category}`,
|
||||
});
|
||||
|
||||
for (const session of groupedSessions[category]) {
|
||||
items.push({
|
||||
type: 'session',
|
||||
session,
|
||||
isPinned: false,
|
||||
id: `session-${session.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +152,15 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
}
|
||||
|
||||
return items;
|
||||
}, [pinnedSessions, nonEmptyCategories, groupedSessions, sessionsHasMore]);
|
||||
}, [
|
||||
sessionSortMode,
|
||||
contextSortedSessions,
|
||||
pinnedSessionIds,
|
||||
pinnedSessions,
|
||||
nonEmptyCategories,
|
||||
groupedSessions,
|
||||
sessionsHasMore,
|
||||
]);
|
||||
|
||||
// Estimate item size based on type
|
||||
const estimateSize = useCallback(
|
||||
|
|
@ -273,12 +302,53 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
className="text-xs uppercase tracking-wider"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Sessions
|
||||
{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}
|
||||
</h2>
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */}
|
||||
<span
|
||||
ref={countRef}
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}
|
||||
onMouseEnter={() => setShowCountTooltip(true)}
|
||||
onMouseLeave={() => setShowCountTooltip(false)}
|
||||
>
|
||||
({sessions.length}
|
||||
{sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''})
|
||||
{sessionsHasMore ? '+' : ''})
|
||||
</span>
|
||||
{showCountTooltip &&
|
||||
sessionsHasMore &&
|
||||
countRef.current &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 w-48 rounded-md px-2.5 py-1.5 text-[11px] leading-snug shadow-lg"
|
||||
style={{
|
||||
top: countRef.current.getBoundingClientRect().bottom + 6,
|
||||
left:
|
||||
countRef.current.getBoundingClientRect().left +
|
||||
countRef.current.getBoundingClientRect().width / 2 -
|
||||
96,
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
border: '1px solid var(--color-border-emphasis)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{sessions.length} loaded so far — scroll down to load more. Context sorting only ranks
|
||||
loaded sessions.
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')
|
||||
}
|
||||
className="ml-auto rounded p-1 transition-colors hover:bg-white/5"
|
||||
title={sessionSortMode === 'recent' ? 'Sort by context consumption' : 'Sort by recent'}
|
||||
style={{
|
||||
color: sessionSortMode === 'most-context' ? '#818cf8' : 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
<ArrowDownWideNarrow className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={parentRef} className="flex-1 overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@
|
|||
* Supports right-click context menu for pane management.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { MessageSquare, Pin } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -16,7 +17,7 @@ import { OngoingIndicator } from '../common/OngoingIndicator';
|
|||
|
||||
import { SessionContextMenu } from './SessionContextMenu';
|
||||
|
||||
import type { Session } from '@renderer/types/data';
|
||||
import type { PhaseTokenBreakdown, Session } from '@renderer/types/data';
|
||||
|
||||
interface SessionItemProps {
|
||||
session: Session;
|
||||
|
|
@ -46,6 +47,84 @@ function formatShortTime(date: Date): string {
|
|||
.replace(' year', 'y');
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumption badge with hover popover showing phase breakdown.
|
||||
*/
|
||||
const ConsumptionBadge = ({
|
||||
contextConsumption,
|
||||
phaseBreakdown,
|
||||
}: Readonly<{
|
||||
contextConsumption: number;
|
||||
phaseBreakdown?: PhaseTokenBreakdown[];
|
||||
}>): React.JSX.Element => {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
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="tabular-nums"
|
||||
style={{ color: isHigh ? 'rgb(251, 191, 36)' : undefined }}
|
||||
onMouseEnter={() => setShowPopover(true)}
|
||||
onMouseLeave={() => setShowPopover(false)}
|
||||
>
|
||||
{formatTokensCompact(contextConsumption)}
|
||||
{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)' }}>
|
||||
Phase {phase.phaseNumber}:
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export const SessionItem = ({
|
||||
session,
|
||||
isActive,
|
||||
|
|
@ -162,7 +241,7 @@ export const SessionItem = ({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second line: message count + time */}
|
||||
{/* Second line: message count + time + context consumption */}
|
||||
<div
|
||||
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
|
|
@ -173,6 +252,15 @@ export const SessionItem = ({
|
|||
</span>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
|
||||
{session.contextConsumption != null && session.contextConsumption > 0 && (
|
||||
<>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<ConsumptionBadge
|
||||
contextConsumption={session.contextConsumption}
|
||||
phaseBreakdown={session.phaseBreakdown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { api } from '@renderer/api';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { Session } from '@renderer/types/data';
|
||||
import type { Session, SessionSortMode } from '@renderer/types/data';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
const logger = createLogger('Store:session');
|
||||
|
|
@ -34,6 +34,8 @@ export interface SessionSlice {
|
|||
sessionsLoadingMore: boolean;
|
||||
// Pinned sessions
|
||||
pinnedSessionIds: string[];
|
||||
// Sort mode
|
||||
sessionSortMode: SessionSortMode;
|
||||
|
||||
// Actions
|
||||
fetchSessions: (projectId: string) => Promise<void>;
|
||||
|
|
@ -48,6 +50,8 @@ export interface SessionSlice {
|
|||
togglePinSession: (sessionId: string) => Promise<void>;
|
||||
/** Load pinned sessions from config for current project */
|
||||
loadPinnedSessions: () => Promise<void>;
|
||||
/** Set session sort mode */
|
||||
setSessionSortMode: (mode: SessionSortMode) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -67,6 +71,8 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
sessionsLoadingMore: false,
|
||||
// Pinned sessions
|
||||
pinnedSessionIds: [],
|
||||
// Sort mode
|
||||
sessionSortMode: 'recent' as SessionSortMode,
|
||||
|
||||
// Fetch sessions for a specific project (legacy - not paginated)
|
||||
fetchSessions: async (projectId: string) => {
|
||||
|
|
@ -317,4 +323,9 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
set({ pinnedSessionIds: [] });
|
||||
}
|
||||
},
|
||||
|
||||
// Set session sort mode
|
||||
setSessionSortMode: (mode: SessionSortMode) => {
|
||||
set({ sessionSortMode: mode });
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ export interface ToolTokenBreakdown {
|
|||
tokenCount: number;
|
||||
/** Whether the tool execution resulted in an error */
|
||||
isError: boolean;
|
||||
/** Tool use ID for deep-link navigation to specific tool in chat */
|
||||
toolUseId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
// Domain types
|
||||
export type {
|
||||
PhaseTokenBreakdown,
|
||||
Project,
|
||||
RepositoryGroup,
|
||||
SearchResult,
|
||||
|
|
@ -68,6 +69,13 @@ export type {
|
|||
TriggerToolName,
|
||||
} from './notifications';
|
||||
|
||||
// =============================================================================
|
||||
// Session Sort Mode
|
||||
// =============================================================================
|
||||
|
||||
/** Sort mode for session list in sidebar */
|
||||
export type SessionSortMode = 'recent' | 'most-context';
|
||||
|
||||
// =============================================================================
|
||||
// Renderer-Specific Type Guards
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ function aggregateToolOutputs(
|
|||
toolName: displayName,
|
||||
tokenCount: toolTokenCount,
|
||||
isError: linkedTool.result?.isError ?? false,
|
||||
toolUseId: linkedTool.id,
|
||||
});
|
||||
totalTokens += toolTokenCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ describe('jsonl', () => {
|
|||
|
||||
expect(result.firstUserMessage?.text).toBe('hello world');
|
||||
expect(result.firstUserMessage?.timestamp).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(result.messageCount).toBe(1);
|
||||
expect(result.messageCount).toBe(2);
|
||||
expect(result.isOngoing).toBe(true);
|
||||
expect(result.gitBranch).toBe('feature/test');
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Reference in a new issue