-
+
Updating app
{clampedPercent}%
-
+
{
{/* Dismiss */}
diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx
index 593be370..a85afb05 100644
--- a/src/renderer/components/dashboard/DashboardView.tsx
+++ b/src/renderer/components/dashboard/DashboardView.tsx
@@ -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 (
@@ -415,14 +416,24 @@ export const DashboardView = (): React.JSX.Element => {
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
- {searchQuery.trim() && (
+
+ {searchQuery.trim() && (
+ setSearchQuery('')}
+ className="text-xs text-text-muted transition-colors hover:text-text-secondary"
+ >
+ Clear search
+
+ )}
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
+
+ Change default folder
- )}
+
{/* Projects Grid */}
diff --git a/src/renderer/components/layout/SidebarHeader.tsx b/src/renderer/components/layout/SidebarHeader.tsx
index 79cf254f..d3cbd291 100644
--- a/src/renderer/components/layout/SidebarHeader.tsx
+++ b/src/renderer/components/layout/SidebarHeader.tsx
@@ -333,9 +333,7 @@ export const SidebarHeader = (): React.JSX.Element => {
style={
{
height: `${HEADER_ROW1_HEIGHT}px`,
- paddingLeft: isMacElectron
- ? 'var(--macos-traffic-light-padding-left, 72px)'
- : '16px',
+ paddingLeft: isMacElectron ? 'var(--macos-traffic-light-padding-left, 72px)' : '16px',
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
} as React.CSSProperties
}
diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx
index 9cf37c90..6748a0a5 100644
--- a/src/renderer/components/layout/TabBar.tsx
+++ b/src/renderer/components/layout/TabBar.tsx
@@ -48,6 +48,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
splitPane,
togglePinSession,
pinnedSessionIds,
+ toggleHideSession,
+ hiddenSessionIds,
} = useStore(
useShallow((s) => ({
pane: s.paneLayout.panes.find((p) => p.id === paneId),
@@ -72,6 +74,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
splitPane: s.splitPane,
togglePinSession: s.togglePinSession,
pinnedSessionIds: s.pinnedSessionIds,
+ toggleHideSession: s.toggleHideSession,
+ hiddenSessionIds: s.hiddenSessionIds,
}))
);
@@ -235,6 +239,10 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
isContextMenuTabSession && contextMenuTab?.sessionId
? pinnedSessionIds.includes(contextMenuTab.sessionId)
: false;
+ const isContextMenuTabHidden =
+ isContextMenuTabSession && contextMenuTab?.sessionId
+ ? hiddenSessionIds.includes(contextMenuTab.sessionId)
+ : false;
// Show sidebar expand button only in the leftmost pane
const isLeftmostPane = useStore(
@@ -384,22 +392,20 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
)}
- {/* Settings gear icon (Electron only - browser can't access native settings) */}
- {isElectronMode() && (
-
setSettingsHover(true)}
- onMouseLeave={() => setSettingsHover(false)}
- className="rounded-md p-2 transition-colors"
- style={{
- color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
- backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
- }}
- title="Settings"
- >
-
-
- )}
+ {/* Settings gear icon */}
+
openSettingsTab()}
+ onMouseEnter={() => setSettingsHover(true)}
+ onMouseLeave={() => setSettingsHover(false)}
+ className="rounded-md p-2 transition-colors"
+ style={{
+ color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
+ backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
+ }}
+ title="Settings"
+ >
+
+
{/* Context menu */}
@@ -427,6 +433,12 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
? () => togglePinSession(contextMenuTab.sessionId!)
: undefined
}
+ isHidden={isContextMenuTabHidden}
+ onToggleHide={
+ isContextMenuTabSession && contextMenuTab?.sessionId
+ ? () => toggleHideSession(contextMenuTab.sessionId!)
+ : undefined
+ }
/>
)}
diff --git a/src/renderer/components/layout/TabContextMenu.tsx b/src/renderer/components/layout/TabContextMenu.tsx
index dd7165ea..cd3fedf8 100644
--- a/src/renderer/components/layout/TabContextMenu.tsx
+++ b/src/renderer/components/layout/TabContextMenu.tsx
@@ -27,6 +27,10 @@ interface TabContextMenuProps {
isPinned?: boolean;
/** Callback to toggle pin state */
onTogglePin?: () => void;
+ /** Whether this session is currently hidden from the sidebar */
+ isHidden?: boolean;
+ /** Callback to toggle hide state */
+ onToggleHide?: () => void;
}
export const TabContextMenu = ({
@@ -44,6 +48,8 @@ export const TabContextMenu = ({
isSessionTab,
isPinned,
onTogglePin,
+ isHidden,
+ onToggleHide,
}: TabContextMenuProps): React.JSX.Element => {
const menuRef = useRef
(null);
@@ -114,6 +120,12 @@ export const TabContextMenu = ({
/>
>
)}
+ {isSessionTab && onToggleHide && (
+
+ )}
diff --git a/src/renderer/components/settings/SettingsTabs.tsx b/src/renderer/components/settings/SettingsTabs.tsx
index 2eb1e9f2..4995d64e 100644
--- a/src/renderer/components/settings/SettingsTabs.tsx
+++ b/src/renderer/components/settings/SettingsTabs.tsx
@@ -1,5 +1,6 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
+import { isElectronMode } from '@renderer/api';
import { Bell, HardDrive, Server, Settings, Wrench } from 'lucide-react';
export type SettingsSection = 'general' | 'connection' | 'workspace' | 'notifications' | 'advanced';
@@ -13,12 +14,13 @@ interface TabConfig {
id: SettingsSection;
label: string;
icon: React.ComponentType<{ className?: string }>;
+ electronOnly?: boolean;
}
const tabs: TabConfig[] = [
{ id: 'general', label: 'General', icon: Settings },
- { id: 'connection', label: 'Connection', icon: Server },
- { id: 'workspace', label: 'Workspaces', icon: HardDrive },
+ { id: 'connection', label: 'Connection', icon: Server, electronOnly: true },
+ { id: 'workspace', label: 'Workspaces', icon: HardDrive, electronOnly: true },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'advanced', label: 'Advanced', icon: Wrench },
];
@@ -28,10 +30,15 @@ export const SettingsTabs = ({
onSectionChange,
}: Readonly
): React.JSX.Element => {
const [hoveredTab, setHoveredTab] = useState(null);
+ const isElectron = useMemo(() => isElectronMode(), []);
+ const visibleTabs = useMemo(
+ () => tabs.filter((tab) => !tab.electronOnly || isElectron),
+ [isElectron]
+ );
return (
- {tabs.map((tab) => {
+ {visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeSection === tab.id;
const isHovered = hoveredTab === tab.id;
diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx
index 904149a7..ee4f0400 100644
--- a/src/renderer/components/settings/SettingsView.tsx
+++ b/src/renderer/components/settings/SettingsView.tsx
@@ -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('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(null);
+ if (pendingSettingsSection !== prevPending) {
+ setPrevPending(pendingSettingsSection);
+ if (pendingSettingsSection) {
+ setActiveSection(pendingSettingsSection as SettingsSection);
+ clearPendingSettingsSection();
+ }
+ }
const {
config,
diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
index e24add0c..5d6941b0 100644
--- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts
+++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
@@ -295,6 +295,7 @@ export function useSettingsHandlers({
},
sessions: {
pinnedSessions: {},
+ hiddenSessions: {},
},
};
diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx
index 3511067b..85925a7d 100644
--- a/src/renderer/components/settings/sections/AdvancedSection.tsx
+++ b/src/renderer/components/settings/sections/AdvancedSection.tsx
@@ -2,9 +2,9 @@
* AdvancedSection - Advanced settings including config management and about info.
*/
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { api } from '@renderer/api';
+import { api, isElectronMode } from '@renderer/api';
import appIcon from '@renderer/favicon.png';
import { useStore } from '@renderer/store';
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
@@ -26,6 +26,7 @@ export const AdvancedSection = ({
onImportConfig,
onOpenInEditor,
}: AdvancedSectionProps): React.JSX.Element => {
+ const isElectron = useMemo(() => isElectronMode(), []);
const [version, setVersion] = useState('');
const updateStatus = useStore((s) => s.updateStatus);
const availableVersion = useStore((s) => s.availableVersion);
@@ -128,17 +129,19 @@ export const AdvancedSection = ({
Import Config
-
-
- Open in Editor
-
+ {isElectron && (
+
+
+ Open in Editor
+
+ )}
@@ -149,22 +152,35 @@ export const AdvancedSection = ({
claude-devtools
-
- {getUpdateButtonContent()}
-
+ {isElectron && (
+
+ {getUpdateButtonContent()}
+
+ )}
+ {!isElectron && (
+
+ Standalone
+
+ )}
Version {version || '...'}
diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx
index b44f8341..8da99046 100644
--- a/src/renderer/components/settings/sections/ConnectionSection.tsx
+++ b/src/renderer/components/settings/sections/ConnectionSection.tsx
@@ -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([]);
const [selectedProfileId, setSelectedProfileId] = useState(null);
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);
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 => {
- 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 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 (
-
-
- Choose which local folder is treated as your Claude data root
-
-
-
-
-
- {resolvedClaudeRootPath}
-
-
- Auto-detected: {defaultClaudeRootPath}
-
-
-
-
-
- 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)',
- }}
- >
-
- {updatingClaudeRoot ? (
-
- ) : (
-
- )}
- Select Folder
-
-
-
- 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)',
- }}
- >
-
-
- Use Auto-Detect
-
-
-
- {isWindowsStyleDefaultPath && (
- 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)',
- }}
- >
-
- {findingWslRoots ? (
-
- ) : (
-
- )}
- Using Linux/WSL?
-
-
- )}
-
-
- {claudeRootError && (
-
- )}
-
- {showWslModal && (
-
-
setShowWslModal(false)}
- aria-label="Close WSL path modal"
- tabIndex={-1}
- />
-
-
- Select WSL Claude Root
-
-
- Detected WSL distributions and Claude root candidates
-
-
-
- {wslCandidates.map((candidate) => (
-
-
-
- {candidate.distro}
-
-
- {candidate.path}
-
- {!candidate.hasProjectsDir && (
-
No projects directory detected
- )}
-
-
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
-
-
- ))}
-
-
-
- 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
-
- {
- 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
-
-
-
-
- )}
-
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..cd28a176 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 { useCallback, useEffect, useMemo, useState } from 'react';
-import { api } from '@renderer/api';
-import { Check, Copy, Loader2 } from 'lucide-react';
+import { api, isElectronMode } from '@renderer/api';
+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,24 +95,186 @@ 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('\\\\');
+
+ const isElectron = useMemo(() => isElectronMode(), []);
+
return (
-
-
- onGeneralToggle('launchAtLogin', v)}
- disabled={saving}
- />
-
- {window.navigator.userAgent.includes('Macintosh') && (
-
- onGeneralToggle('showDockIcon', v)}
- disabled={saving}
- />
-
+ {isElectron && (
+ <>
+
+
+ onGeneralToggle('launchAtLogin', v)}
+ disabled={saving}
+ />
+
+ {window.navigator.userAgent.includes('Macintosh') && (
+
+ onGeneralToggle('showDockIcon', v)}
+ disabled={saving}
+ />
+
+ )}
+ >
)}
@@ -94,53 +287,283 @@ export const GeneralSection = ({
/>
-
-
- {serverLoading ? (
-
- ) : (
-
- )}
-
+ {isElectron && (
+ <>
+
+
+ Choose which local folder is treated as your Claude data root
+
- {serverStatus.running && (
-
-
-
- Running on
-
-
- {serverUrl}
-
-
+
+ {resolvedClaudeRootPath}
+
+
+ Auto-detected: {defaultClaudeRootPath}
+
+
+
+
+
+ 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)',
+ }}
+ >
+
+ {updatingClaudeRoot ? (
+
+ ) : (
+
+ )}
+ Select Folder
+
+
+
+ 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)',
+ }}
+ >
+
+
+ Use Auto-Detect
+
+
+
+ {isWindowsStyleDefaultPath && (
+ 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)',
+ }}
+ >
+
+ {findingWslRoots ? (
+
+ ) : (
+
+ )}
+ Using Linux/WSL?
+
+
+ )}
+
+
+ {claudeRootError && (
+
+ )}
+
+ {showWslModal && (
+
+
setShowWslModal(false)}
+ aria-label="Close WSL path modal"
+ tabIndex={-1}
+ />
+
+
+ Select WSL Claude Root
+
+
+ Detected WSL distributions and Claude root candidates
+
+
+
+ {wslCandidates.map((candidate) => (
+
+
+
+ {candidate.distro}
+
+
+ {candidate.path}
+
+ {!candidate.hasProjectsDir && (
+
+ No projects directory detected
+
+ )}
+
+
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
+
+
+ ))}
+
+
+
+ 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
+
+ {
+ 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
+
+
+
+
+ )}
+ >
+ )}
+
+ {isElectron ? (
+ <>
+
+
- {copied ? : }
- {copied ? 'Copied' : 'Copy URL'}
-
-
+ {serverLoading ? (
+
+ ) : (
+
+ )}
+
+
+ {serverStatus.running && (
+
+
+
+ Running on
+
+
+ {serverUrl}
+
+
+ {copied ? : }
+ {copied ? 'Copied' : 'Copy URL'}
+
+
+ )}
+ >
+ ) : (
+ <>
+
+
+
+
+ Running on
+
+
+ {window.location.origin}
+
+
{
+ void navigator.clipboard.writeText(window.location.origin);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }}
+ className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
+ style={{
+ borderColor: 'var(--color-border)',
+ color: copied ? '#22c55e' : 'var(--color-text-secondary)',
+ }}
+ >
+ {copied ? : }
+ {copied ? 'Copied' : 'Copy URL'}
+
+
+
+ Running in standalone mode. The HTTP server is always active. System notifications are
+ not available — notification triggers are logged in-app only.
+
+ >
)}
);
diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx
index 48b6aa47..62ee6f4c 100644
--- a/src/renderer/components/sidebar/DateGroupedSessions.tsx
+++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx
@@ -1,9 +1,11 @@
/**
* DateGroupedSessions - Sessions organized by date categories with virtual scrolling.
* Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll.
+ * Supports multi-select with bulk actions and hidden session filtering.
*/
-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 +14,17 @@ import {
separatePinnedSessions,
} from '@renderer/utils/dateGrouping';
import { useVirtualizer } from '@tanstack/react-virtual';
-import { Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react';
+import {
+ ArrowDownWideNarrow,
+ Calendar,
+ CheckSquare,
+ Eye,
+ EyeOff,
+ Loader2,
+ MessageSquareOff,
+ Pin,
+ X,
+} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SessionItem } from './SessionItem';
@@ -24,7 +36,7 @@ import type { DateCategory } from '@renderer/types/tabs';
type VirtualItem =
| { type: 'header'; category: DateCategory; id: string }
| { type: 'pinned-header'; id: string }
- | { type: 'session'; session: Session; isPinned: boolean; id: string }
+ | { type: 'session'; session: Session; isPinned: boolean; isHidden: boolean; id: string }
| { type: 'loader'; id: string };
/**
@@ -47,9 +59,21 @@ export const DateGroupedSessions = (): React.JSX.Element => {
sessionsError,
sessionsHasMore,
sessionsLoadingMore,
- sessionsTotalCount,
fetchSessionsMore,
pinnedSessionIds,
+ sessionSortMode,
+ setSessionSortMode,
+ hiddenSessionIds,
+ showHiddenSessions,
+ toggleShowHiddenSessions,
+ sidebarSelectedSessionIds,
+ sidebarMultiSelectActive,
+ toggleSidebarSessionSelection,
+ clearSidebarSelection,
+ toggleSidebarMultiSelect,
+ hideMultipleSessions,
+ unhideMultipleSessions,
+ pinMultipleSessions,
} = useStore(
useShallow((s) => ({
sessions: s.sessions,
@@ -59,18 +83,41 @@ 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,
+ hiddenSessionIds: s.hiddenSessionIds,
+ showHiddenSessions: s.showHiddenSessions,
+ toggleShowHiddenSessions: s.toggleShowHiddenSessions,
+ sidebarSelectedSessionIds: s.sidebarSelectedSessionIds,
+ sidebarMultiSelectActive: s.sidebarMultiSelectActive,
+ toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
+ clearSidebarSelection: s.clearSidebarSelection,
+ toggleSidebarMultiSelect: s.toggleSidebarMultiSelect,
+ hideMultipleSessions: s.hideMultipleSessions,
+ unhideMultipleSessions: s.unhideMultipleSessions,
+ pinMultipleSessions: s.pinMultipleSessions,
}))
);
const parentRef = useRef(null);
+ const countRef = useRef(null);
+ const [showCountTooltip, setShowCountTooltip] = useState(false);
+
+ const hiddenSet = useMemo(() => new Set(hiddenSessionIds), [hiddenSessionIds]);
+ const hasHiddenSessions = hiddenSessionIds.length > 0;
+
+ // Filter out hidden sessions unless showHiddenSessions is on
+ const visibleSessions = useMemo(() => {
+ if (showHiddenSessions) return sessions;
+ return sessions.filter((s) => !hiddenSet.has(s.id));
+ }, [sessions, hiddenSet, showHiddenSessions]);
// Separate pinned sessions from unpinned
const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo(
- () => separatePinnedSessions(sessions, pinnedSessionIds),
- [sessions, pinnedSessionIds]
+ () => separatePinnedSessions(visibleSessions, pinnedSessionIds),
+ [visibleSessions, pinnedSessionIds]
);
// Group only unpinned sessions by date
@@ -82,43 +129,64 @@ 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 [...visibleSessions].sort(
+ (a, b) => (b.contextConsumption ?? 0) - (a.contextConsumption ?? 0)
+ );
+ }, [visibleSessions, 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),
+ isHidden: hiddenSet.has(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,
+ isHidden: hiddenSet.has(session.id),
+ 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,
+ isHidden: hiddenSet.has(session.id),
+ id: `session-${session.id}`,
+ });
+ }
}
}
@@ -131,7 +199,16 @@ export const DateGroupedSessions = (): React.JSX.Element => {
}
return items;
- }, [pinnedSessions, nonEmptyCategories, groupedSessions, sessionsHasMore]);
+ }, [
+ sessionSortMode,
+ contextSortedSessions,
+ pinnedSessionIds,
+ hiddenSet,
+ pinnedSessions,
+ nonEmptyCategories,
+ groupedSessions,
+ sessionsHasMore,
+ ]);
// Estimate item size based on type
const estimateSize = useCallback(
@@ -192,6 +269,32 @@ export const DateGroupedSessions = (): React.JSX.Element => {
fetchSessionsMore,
]);
+ // Bulk action helpers
+ const selectedSet = useMemo(
+ () => new Set(sidebarSelectedSessionIds),
+ [sidebarSelectedSessionIds]
+ );
+ const someSelectedAreHidden = useMemo(
+ () => sidebarSelectedSessionIds.some((id) => hiddenSet.has(id)),
+ [sidebarSelectedSessionIds, hiddenSet]
+ );
+
+ const handleBulkHide = useCallback(() => {
+ void hideMultipleSessions(sidebarSelectedSessionIds);
+ clearSidebarSelection();
+ }, [hideMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]);
+
+ const handleBulkUnhide = useCallback(() => {
+ const hiddenSelected = sidebarSelectedSessionIds.filter((id) => hiddenSet.has(id));
+ void unhideMultipleSessions(hiddenSelected);
+ clearSidebarSelection();
+ }, [unhideMultipleSessions, sidebarSelectedSessionIds, hiddenSet, clearSidebarSelection]);
+
+ const handleBulkPin = useCallback(() => {
+ void pinMultipleSessions(sidebarSelectedSessionIds);
+ clearSidebarSelection();
+ }, [pinMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]);
+
if (!selectedProjectId) {
return (
@@ -273,14 +376,136 @@ 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'}
-
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */}
+ setShowCountTooltip(true)}
+ onMouseLeave={() => setShowCountTooltip(false)}
+ >
({sessions.length}
- {sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''})
+ {sessionsHasMore ? '+' : ''})
+ {showCountTooltip &&
+ sessionsHasMore &&
+ countRef.current &&
+ createPortal(
+
+ {sessions.length} loaded so far — scroll down to load more. Context sorting only ranks
+ loaded sessions.
+
,
+ document.body
+ )}
+
+ {/* Multi-select toggle */}
+
+
+
+ {/* Show hidden sessions toggle - only when hidden sessions exist */}
+ {hasHiddenSessions && (
+
+ {showHiddenSessions ? : }
+
+ )}
+ {/* Sort mode toggle */}
+
+ setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')
+ }
+ className="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)',
+ }}
+ >
+
+
+
+ {/* Bulk action bar - shown when sessions are selected */}
+ {sidebarMultiSelectActive && sidebarSelectedSessionIds.length > 0 && (
+
+
+ {sidebarSelectedSessionIds.length} selected
+
+
+
+ Pin
+
+
+ Hide
+
+ {showHiddenSessions && someSelectedAreHidden && (
+
+ Unhide
+
+ )}
+
+
+
+
+
+ )}
+
{
session={item.session}
isActive={selectedSessionId === item.session.id}
isPinned={item.isPinned}
+ isHidden={item.isHidden}
+ multiSelectActive={sidebarMultiSelectActive}
+ isSelected={selectedSet.has(item.session.id)}
+ onToggleSelect={() => toggleSidebarSessionSelection(item.session.id)}
/>
)}
diff --git a/src/renderer/components/sidebar/SessionContextMenu.tsx b/src/renderer/components/sidebar/SessionContextMenu.tsx
index 37acea03..9aa1073b 100644
--- a/src/renderer/components/sidebar/SessionContextMenu.tsx
+++ b/src/renderer/components/sidebar/SessionContextMenu.tsx
@@ -7,7 +7,7 @@
import { useEffect, useRef } from 'react';
import { MAX_PANES } from '@renderer/types/panes';
-import { Pin, PinOff } from 'lucide-react';
+import { Eye, EyeOff, Pin, PinOff } from 'lucide-react';
interface SessionContextMenuProps {
x: number;
@@ -17,11 +17,13 @@ interface SessionContextMenuProps {
sessionLabel: string;
paneCount: number;
isPinned: boolean;
+ isHidden: boolean;
onClose: () => void;
onOpenInCurrentPane: () => void;
onOpenInNewTab: () => void;
onSplitRightAndOpen: () => void;
onTogglePin: () => void;
+ onToggleHide: () => void;
}
export const SessionContextMenu = ({
@@ -29,11 +31,13 @@ export const SessionContextMenu = ({
y,
paneCount,
isPinned,
+ isHidden,
onClose,
onOpenInCurrentPane,
onOpenInNewTab,
onSplitRightAndOpen,
onTogglePin,
+ onToggleHide,
}: SessionContextMenuProps): React.JSX.Element => {
const menuRef = useRef
(null);
@@ -55,7 +59,7 @@ export const SessionContextMenu = ({
}, [onClose]);
const menuWidth = 240;
- const menuHeight = 180;
+ const menuHeight = 204;
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
@@ -92,6 +96,11 @@ export const SessionContextMenu = ({
icon={isPinned ? : }
onClick={handleClick(onTogglePin)}
/>
+ : }
+ onClick={handleClick(onToggleHide)}
+ />
);
};
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx
index 84e34590..8d756d86 100644
--- a/src/renderer/components/sidebar/SessionItem.tsx
+++ b/src/renderer/components/sidebar/SessionItem.tsx
@@ -4,24 +4,29 @@
* 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 { EyeOff, MessageSquare, Pin } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
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;
isActive?: boolean;
isPinned?: boolean;
+ isHidden?: boolean;
+ multiSelectActive?: boolean;
+ isSelected?: boolean;
+ onToggleSelect?: () => void;
}
/**
@@ -46,28 +51,125 @@ 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 [popoverPosition, setPopoverPosition] = useState<{
+ top: number;
+ left: number;
+ } | null>(null);
+ const badgeRef = useRef(null);
+ const isHigh = contextConsumption > 150_000;
+
+ const showPopover = popoverPosition !== null;
+
+ return (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive
+ {
+ const rect = badgeRef.current?.getBoundingClientRect();
+ if (rect) {
+ setPopoverPosition({
+ top: rect.top - 6,
+ left: rect.left + rect.width / 2,
+ });
+ }
+ }}
+ onMouseLeave={() => setPopoverPosition(null)}
+ >
+ {formatTokensCompact(contextConsumption)}
+ {showPopover &&
+ popoverPosition &&
+ phaseBreakdown &&
+ phaseBreakdown.length > 0 &&
+ createPortal(
+
+
+ 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 && (
+
+ (compacted to {formatTokensCompact(phase.postCompaction)})
+
+ )}
+
+ ))
+ )}
+
,
+ document.body
+ )}
+
+ );
+};
+
export const SessionItem = ({
session,
isActive,
isPinned,
+ isHidden,
+ multiSelectActive,
+ isSelected,
+ onToggleSelect,
}: Readonly): React.JSX.Element => {
- const { openTab, activeProjectId, selectSession, paneCount, splitPane, togglePinSession } =
- useStore(
- useShallow((s) => ({
- openTab: s.openTab,
- activeProjectId: s.activeProjectId,
- selectSession: s.selectSession,
- paneCount: s.paneLayout.panes.length,
- splitPane: s.splitPane,
- togglePinSession: s.togglePinSession,
- }))
- );
+ const {
+ openTab,
+ activeProjectId,
+ selectSession,
+ paneCount,
+ splitPane,
+ togglePinSession,
+ toggleHideSession,
+ } = useStore(
+ useShallow((s) => ({
+ openTab: s.openTab,
+ activeProjectId: s.activeProjectId,
+ selectSession: s.selectSession,
+ paneCount: s.paneLayout.panes.length,
+ splitPane: s.splitPane,
+ togglePinSession: s.togglePinSession,
+ toggleHideSession: s.toggleHideSession,
+ }))
+ );
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const handleClick = (event: React.MouseEvent): void => {
if (!activeProjectId) return;
+ // In multi-select mode, clicks toggle selection
+ if (multiSelectActive && onToggleSelect) {
+ onToggleSelect();
+ return;
+ }
+
// Cmd/Ctrl+click: open in new tab; plain click: replace current tab
const forceNewTab = event.ctrlKey || event.metaKey;
@@ -148,12 +250,23 @@ export const SessionItem = ({
style={{
borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
+ ...(isHidden ? { opacity: 0.5 } : {}),
}}
>
- {/* First line: title + ongoing indicator + pin icon */}
+ {/* First line: title + ongoing indicator + pin/hidden icons */}
+ {multiSelectActive && (
+
onToggleSelect?.()}
+ onClick={(e) => e.stopPropagation()}
+ className="size-3.5 shrink-0 accent-blue-500"
+ />
+ )}
{session.isOngoing &&
}
{isPinned &&
}
+ {isHidden &&
}
- {/* Second line: message count + time */}
+ {/* Second line: message count + time + context consumption */}
·
{formatShortTime(new Date(session.createdAt))}
+ {session.contextConsumption != null && session.contextConsumption > 0 && (
+ <>
+ ·
+
+ >
+ )}
@@ -187,11 +309,13 @@ export const SessionItem = ({
sessionLabel={sessionLabel}
paneCount={paneCount}
isPinned={isPinned ?? false}
+ isHidden={isHidden ?? false}
onClose={() => setContextMenu(null)}
onOpenInCurrentPane={handleOpenInCurrentPane}
onOpenInNewTab={handleOpenInNewTab}
onSplitRightAndOpen={handleSplitRightAndOpen}
onTogglePin={() => void togglePinSession(session.id)}
+ onToggleHide={() => void toggleHideSession(session.id)}
/>,
document.body
)}
diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts
index f686fd4a..01114285 100644
--- a/src/renderer/store/index.ts
+++ b/src/renderer/store/index.ts
@@ -221,7 +221,8 @@ export function initializeNotificationListeners(): () => void {
(eventProjectBaseId == null || selectedProjectBaseId === eventProjectBaseId);
const isTopLevelSessionEvent = !event.isSubagent;
const isUnknownSessionInSidebar =
- event.sessionId == null || !state.sessions.some((session) => session.id === event.sessionId);
+ event.sessionId == null ||
+ !state.sessions.some((session) => session.id === event.sessionId);
const shouldRefreshForPotentialNewSession =
isTopLevelSessionEvent &&
matchesSelectedProject &&
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 });
+ },
});
diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts
index 489e8452..fe1862ad 100644
--- a/src/renderer/store/slices/sessionSlice.ts
+++ b/src/renderer/store/slices/sessionSlice.ts
@@ -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,14 @@ export interface SessionSlice {
sessionsLoadingMore: boolean;
// Pinned sessions
pinnedSessionIds: string[];
+ // Hidden sessions
+ hiddenSessionIds: string[];
+ showHiddenSessions: boolean;
+ // Multi-select
+ sidebarSelectedSessionIds: string[];
+ sidebarMultiSelectActive: boolean;
+ // Sort mode
+ sessionSortMode: SessionSortMode;
// Actions
fetchSessions: (projectId: string) => Promise;
@@ -48,6 +56,26 @@ export interface SessionSlice {
togglePinSession: (sessionId: string) => Promise;
/** Load pinned sessions from config for current project */
loadPinnedSessions: () => Promise;
+ /** Set session sort mode */
+ setSessionSortMode: (mode: SessionSortMode) => void;
+ /** Toggle hide/unhide for a session */
+ toggleHideSession: (sessionId: string) => Promise;
+ /** Bulk hide sessions */
+ hideMultipleSessions: (sessionIds: string[]) => Promise;
+ /** Bulk unhide sessions */
+ unhideMultipleSessions: (sessionIds: string[]) => Promise;
+ /** Load hidden sessions from config for current project */
+ loadHiddenSessions: () => Promise;
+ /** Toggle showing hidden sessions in sidebar */
+ toggleShowHiddenSessions: () => void;
+ /** Toggle one session's checkbox in sidebar multi-select */
+ toggleSidebarSessionSelection: (sessionId: string) => void;
+ /** Clear all selections and exit multi-select mode */
+ clearSidebarSelection: () => void;
+ /** Enter/exit selection mode */
+ toggleSidebarMultiSelect: () => void;
+ /** Bulk pin for multi-select */
+ pinMultipleSessions: (sessionIds: string[]) => Promise;
}
// =============================================================================
@@ -67,6 +95,14 @@ export const createSessionSlice: StateCreator =
sessionsLoadingMore: false,
// Pinned sessions
pinnedSessionIds: [],
+ // Hidden sessions
+ hiddenSessionIds: [],
+ showHiddenSessions: false,
+ // Multi-select
+ sidebarSelectedSessionIds: [],
+ sidebarMultiSelectActive: false,
+ // Sort mode
+ sessionSortMode: 'recent' as SessionSortMode,
// Fetch sessions for a specific project (legacy - not paginated)
fetchSessions: async (projectId: string) => {
@@ -109,8 +145,9 @@ export const createSessionSlice: StateCreator =
sessionsLoading: false,
});
- // Load pinned sessions after fetching session list
+ // Load pinned and hidden sessions after fetching session list
void get().loadPinnedSessions();
+ void get().loadHiddenSessions();
} catch (error) {
set({
sessionsError: error instanceof Error ? error.message : 'Failed to fetch sessions',
@@ -317,4 +354,155 @@ export const createSessionSlice: StateCreator =
set({ pinnedSessionIds: [] });
}
},
+
+ // Set session sort mode
+ setSessionSortMode: (mode: SessionSortMode) => {
+ set({ sessionSortMode: mode });
+ },
+
+ // Toggle hide/unhide for a session (optimistic update)
+ toggleHideSession: async (sessionId: string) => {
+ const state = get();
+ const projectId = state.selectedProjectId;
+ if (!projectId) return;
+
+ const isHidden = state.hiddenSessionIds.includes(sessionId);
+ const previousHiddenIds = state.hiddenSessionIds;
+
+ // Optimistic: update UI immediately
+ if (isHidden) {
+ set({ hiddenSessionIds: previousHiddenIds.filter((id) => id !== sessionId) });
+ } else {
+ set({ hiddenSessionIds: [sessionId, ...previousHiddenIds] });
+ }
+
+ try {
+ if (isHidden) {
+ await api.config.unhideSession(projectId, sessionId);
+ } else {
+ await api.config.hideSession(projectId, sessionId);
+ }
+ } catch (error) {
+ // Rollback on failure
+ set({ hiddenSessionIds: previousHiddenIds });
+ logger.error('toggleHideSession error:', error);
+ }
+ },
+
+ // Bulk hide sessions
+ hideMultipleSessions: async (sessionIds: string[]) => {
+ const state = get();
+ const projectId = state.selectedProjectId;
+ if (!projectId || sessionIds.length === 0) return;
+
+ const previousHiddenIds = state.hiddenSessionIds;
+ const existingSet = new Set(previousHiddenIds);
+ const newIds = sessionIds.filter((id) => !existingSet.has(id));
+
+ // Optimistic update
+ set({ hiddenSessionIds: [...newIds, ...previousHiddenIds] });
+
+ try {
+ await api.config.hideSessions(projectId, sessionIds);
+ } catch (error) {
+ set({ hiddenSessionIds: previousHiddenIds });
+ logger.error('hideMultipleSessions error:', error);
+ }
+ },
+
+ // Bulk unhide sessions
+ unhideMultipleSessions: async (sessionIds: string[]) => {
+ const state = get();
+ const projectId = state.selectedProjectId;
+ if (!projectId || sessionIds.length === 0) return;
+
+ const previousHiddenIds = state.hiddenSessionIds;
+ const toRemove = new Set(sessionIds);
+
+ // Optimistic update
+ set({ hiddenSessionIds: previousHiddenIds.filter((id) => !toRemove.has(id)) });
+
+ try {
+ await api.config.unhideSessions(projectId, sessionIds);
+ } catch (error) {
+ set({ hiddenSessionIds: previousHiddenIds });
+ logger.error('unhideMultipleSessions error:', error);
+ }
+ },
+
+ // Load hidden sessions from config for current project
+ loadHiddenSessions: async () => {
+ const state = get();
+ const projectId = state.selectedProjectId;
+ if (!projectId) {
+ set({ hiddenSessionIds: [] });
+ return;
+ }
+
+ try {
+ const config = await api.config.get();
+ const hidden = config.sessions?.hiddenSessions?.[projectId] ?? [];
+ const hiddenIds = hidden.map((h) => h.sessionId);
+ set({ hiddenSessionIds: hiddenIds });
+ } catch (error) {
+ logger.error('loadHiddenSessions error:', error);
+ set({ hiddenSessionIds: [] });
+ }
+ },
+
+ // Toggle showing hidden sessions in sidebar
+ toggleShowHiddenSessions: () => {
+ set((prev) => ({ showHiddenSessions: !prev.showHiddenSessions }));
+ },
+
+ // Toggle one session's checkbox in sidebar multi-select
+ toggleSidebarSessionSelection: (sessionId: string) => {
+ set((prev) => {
+ const selected = prev.sidebarSelectedSessionIds;
+ if (selected.includes(sessionId)) {
+ return { sidebarSelectedSessionIds: selected.filter((id) => id !== sessionId) };
+ }
+ return {
+ sidebarSelectedSessionIds: [...selected, sessionId],
+ sidebarMultiSelectActive: true,
+ };
+ });
+ },
+
+ // Clear all selections and exit multi-select mode
+ clearSidebarSelection: () => {
+ set({ sidebarSelectedSessionIds: [], sidebarMultiSelectActive: false });
+ },
+
+ // Enter/exit selection mode
+ toggleSidebarMultiSelect: () => {
+ set((prev) => {
+ if (prev.sidebarMultiSelectActive) {
+ return { sidebarMultiSelectActive: false, sidebarSelectedSessionIds: [] };
+ }
+ return { sidebarMultiSelectActive: true };
+ });
+ },
+
+ // Bulk pin for multi-select
+ pinMultipleSessions: async (sessionIds: string[]) => {
+ const state = get();
+ const projectId = state.selectedProjectId;
+ if (!projectId || sessionIds.length === 0) return;
+
+ const previousPinnedIds = state.pinnedSessionIds;
+ const existingSet = new Set(previousPinnedIds);
+ const newIds = sessionIds.filter((id) => !existingSet.has(id));
+
+ // Optimistic update
+ set({ pinnedSessionIds: [...newIds, ...previousPinnedIds] });
+
+ try {
+ // Pin each session individually (no bulk pin IPC)
+ await Promise.all(newIds.map((sessionId) => api.config.pinSession(projectId, sessionId)));
+ } catch (error) {
+ set({ pinnedSessionIds: previousPinnedIds });
+ logger.error('pinMultipleSessions error:', error);
+ }
+ },
});
diff --git a/src/renderer/types/contextInjection.ts b/src/renderer/types/contextInjection.ts
index 3ad3be56..86406839 100644
--- a/src/renderer/types/contextInjection.ts
+++ b/src/renderer/types/contextInjection.ts
@@ -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;
}
/**
diff --git a/src/renderer/types/data.ts b/src/renderer/types/data.ts
index 79f5268a..8bc87278 100644
--- a/src/renderer/types/data.ts
+++ b/src/renderer/types/data.ts
@@ -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
// =============================================================================
diff --git a/src/renderer/types/groups.ts b/src/renderer/types/groups.ts
index 938af9a7..639d0436 100644
--- a/src/renderer/types/groups.ts
+++ b/src/renderer/types/groups.ts
@@ -253,7 +253,15 @@ export type AIGroupDisplayItem =
| { type: 'subagent'; subagent: Process }
| { type: 'output'; content: string; timestamp: Date; tokenCount?: number }
| { type: 'slash'; slash: SlashItem }
- | { type: 'teammate_message'; teammateMessage: TeammateMessage };
+ | { type: 'teammate_message'; teammateMessage: TeammateMessage }
+ | { type: 'subagent_input'; content: string; timestamp: Date; tokenCount?: number }
+ | {
+ type: 'compact_boundary';
+ content: string;
+ timestamp: Date;
+ tokenDelta?: CompactionTokenDelta;
+ phaseNumber: number;
+ };
/**
* The last output in an AI Group - what user sees as "the answer".
diff --git a/src/renderer/utils/aiGroupHelpers.ts b/src/renderer/utils/aiGroupHelpers.ts
index f1448ba3..fb07e6d5 100644
--- a/src/renderer/utils/aiGroupHelpers.ts
+++ b/src/renderer/utils/aiGroupHelpers.ts
@@ -7,7 +7,7 @@
import { createLogger } from '@shared/utils/logger';
import { estimateTokens } from '@shared/utils/tokenFormatting';
-import type { Process } from '../types/data';
+import type { ParsedMessage, PhaseTokenBreakdown, Process } from '../types/data';
import type { LinkedToolItem } from '../types/groups';
const logger = createLogger('Util:aiGroupHelpers');
@@ -98,3 +98,111 @@ export function attachMainSessionImpact(
}
return subagents;
}
+
+/**
+ * Computes multi-phase context breakdown for a subagent session.
+ * Mirrors the algorithm in src/main/utils/jsonl.ts:500-576.
+ *
+ * Tracks assistant input tokens across compaction events to compute
+ * per-phase contribution and total consumption across all phases.
+ *
+ * @param messages - Subagent's ParsedMessages
+ * @returns Phase breakdown with total consumption, or null if no usage data
+ */
+export function computeSubagentPhaseBreakdown(messages: ParsedMessage[]): {
+ phases: PhaseTokenBreakdown[];
+ totalConsumption: number;
+ compactionCount: number;
+} | null {
+ let lastMainAssistantInputTokens = 0;
+ let awaitingPostCompaction = false;
+ const compactionPhases: { pre: number; post: number }[] = [];
+
+ for (const msg of messages) {
+ // Track assistant input tokens.
+ // Unlike jsonl.ts, we don't filter by isSidechain here because subagent messages
+ // all have isSidechain=true (from the parent session's perspective).
+ if (msg.type === 'assistant' && msg.model !== '') {
+ const inputTokens =
+ (msg.usage?.input_tokens ?? 0) +
+ (msg.usage?.cache_read_input_tokens ?? 0) +
+ (msg.usage?.cache_creation_input_tokens ?? 0);
+ if (inputTokens > 0) {
+ if (awaitingPostCompaction && compactionPhases.length > 0) {
+ compactionPhases[compactionPhases.length - 1].post = inputTokens;
+ awaitingPostCompaction = false;
+ }
+ lastMainAssistantInputTokens = inputTokens;
+ }
+ }
+
+ // Detect compaction events
+ if (msg.isCompactSummary) {
+ compactionPhases.push({ pre: lastMainAssistantInputTokens, post: 0 });
+ awaitingPostCompaction = true;
+ }
+ }
+
+ if (lastMainAssistantInputTokens <= 0) {
+ return null;
+ }
+
+ let phaseBreakdown: PhaseTokenBreakdown[];
+
+ if (compactionPhases.length === 0) {
+ // No compaction: single phase
+ phaseBreakdown = [
+ {
+ phaseNumber: 1,
+ contribution: lastMainAssistantInputTokens,
+ peakTokens: lastMainAssistantInputTokens,
+ },
+ ];
+ return {
+ phases: phaseBreakdown,
+ totalConsumption: lastMainAssistantInputTokens,
+ compactionCount: 0,
+ };
+ }
+
+ 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,
+ });
+
+ return {
+ phases: phaseBreakdown,
+ totalConsumption: total,
+ compactionCount: compactionPhases.length,
+ };
+}
diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts
index d1b54aef..021afb5a 100644
--- a/src/renderer/utils/contextTracker.ts
+++ b/src/renderer/utils/contextTracker.ts
@@ -214,6 +214,7 @@ function aggregateToolOutputs(
toolName: displayName,
tokenCount: toolTokenCount,
isError: linkedTool.result?.isError ?? false,
+ toolUseId: linkedTool.id,
});
totalTokens += toolTokenCount;
}
diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts
index b0681e70..953dc645 100644
--- a/src/renderer/utils/displayItemBuilder.ts
+++ b/src/renderer/utils/displayItemBuilder.ts
@@ -29,6 +29,9 @@ function getDisplayItemTimestamp(item: AIGroupDisplayItem): Date {
return toDate(item.slash.timestamp);
case 'teammate_message':
return toDate(item.teammateMessage.timestamp);
+ case 'subagent_input':
+ case 'compact_boundary':
+ return toDate(item.timestamp);
}
}
@@ -320,10 +323,76 @@ export function buildDisplayItemsFromMessages(
subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id)
);
+ // Track compaction events for compact_boundary display items
+ let compactionCount = 0;
+
+ // Helper to get the last assistant's total input tokens before a given index
+ // Note: don't filter by isSidechain — subagent messages all have isSidechain=true
+ function getLastAssistantInputTokens(idx: number): number {
+ for (let i = idx - 1; i >= 0; i--) {
+ const m = messages[i];
+ if (m.type === 'assistant' && m.usage && m.model !== '') {
+ return (
+ (m.usage.input_tokens ?? 0) +
+ (m.usage.cache_read_input_tokens ?? 0) +
+ (m.usage.cache_creation_input_tokens ?? 0)
+ );
+ }
+ }
+ return 0;
+ }
+
+ // Helper to get the first assistant's total input tokens after a given index
+ function getFirstAssistantInputTokens(idx: number): number {
+ for (let i = idx + 1; i < messages.length; i++) {
+ const m = messages[i];
+ if (m.type === 'assistant' && m.usage && m.model !== '') {
+ return (
+ (m.usage.input_tokens ?? 0) +
+ (m.usage.cache_read_input_tokens ?? 0) +
+ (m.usage.cache_creation_input_tokens ?? 0)
+ );
+ }
+ }
+ return 0;
+ }
+
// First pass: collect tool calls and tool results from messages
- for (const msg of messages) {
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
+ const msg = messages[messageIndex];
const msgTimestamp = toDate(msg.timestamp);
+ // Detect compact boundary (before regular user message handling)
+ if (msg.isCompactSummary) {
+ const preTokens = getLastAssistantInputTokens(messageIndex);
+ const postTokens = getFirstAssistantInputTokens(messageIndex);
+ const rawText =
+ typeof msg.content === 'string'
+ ? msg.content
+ : Array.isArray(msg.content)
+ ? msg.content
+ .filter((b: { type: string; text?: string }) => b.type === 'text')
+ .map((b: { type: string; text?: string }) => b.text ?? '')
+ .join('\n\n')
+ : '';
+ displayItems.push({
+ type: 'compact_boundary',
+ content: rawText,
+ timestamp: msgTimestamp,
+ tokenDelta:
+ preTokens > 0
+ ? {
+ preCompactionTokens: preTokens,
+ postCompactionTokens: postTokens,
+ delta: postTokens - preTokens,
+ }
+ : undefined,
+ phaseNumber: compactionCount + 2,
+ });
+ compactionCount++;
+ continue;
+ }
+
// Check for teammate messages (non-meta user messages with content)
// One user message may contain multiple blocks
if (msg.type === 'user' && !msg.isMeta) {
@@ -354,6 +423,16 @@ export function buildDisplayItemsFromMessages(
}
continue;
}
+ // Plain-text user message (subagent input prompt)
+ if (rawText.trim()) {
+ displayItems.push({
+ type: 'subagent_input',
+ content: rawText.trim(),
+ timestamp: msgTimestamp,
+ tokenCount: estimateTokens(rawText),
+ });
+ }
+ continue;
}
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
diff --git a/src/renderer/utils/displaySummary.ts b/src/renderer/utils/displaySummary.ts
index 28c6c31d..942a8c59 100644
--- a/src/renderer/utils/displaySummary.ts
+++ b/src/renderer/utils/displaySummary.ts
@@ -26,6 +26,8 @@ export function buildSummary(items: AIGroupDisplayItem[]): string {
subagent: 0,
slash: 0,
teammate_message: 0,
+ subagent_input: 0,
+ compact_boundary: 0,
};
const teammateNames = new Set();
@@ -62,6 +64,11 @@ export function buildSummary(items: AIGroupDisplayItem[]): string {
`${counts.teammate_message} teammate ${counts.teammate_message === 1 ? 'message' : 'messages'}`
);
}
+ if (counts.compact_boundary > 0) {
+ parts.push(
+ `${counts.compact_boundary} ${counts.compact_boundary === 1 ? 'compaction' : 'compactions'}`
+ );
+ }
return parts.length > 0 ? parts.join(', ') : 'No items';
}
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index 47a00364..4d405b02 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -99,6 +99,14 @@ export interface ConfigAPI {
pinSession: (projectId: string, sessionId: string) => Promise;
/** Unpin a session for a project */
unpinSession: (projectId: string, sessionId: string) => Promise;
+ /** Hide a session for a project */
+ hideSession: (projectId: string, sessionId: string) => Promise;
+ /** Unhide a session for a project */
+ unhideSession: (projectId: string, sessionId: string) => Promise;
+ /** Bulk hide sessions for a project */
+ hideSessions: (projectId: string, sessionIds: string[]) => Promise;
+ /** Bulk unhide sessions for a project */
+ unhideSessions: (projectId: string, sessionIds: string[]) => Promise;
}
export interface ClaudeRootInfo {
diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts
index 84a133dc..245663ab 100644
--- a/src/shared/types/notifications.ts
+++ b/src/shared/types/notifications.ts
@@ -276,6 +276,8 @@ export interface AppConfig {
sessions: {
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
pinnedSessions: Record;
+ /** Hidden sessions per project. Key is projectId, value is array of hidden sessions */
+ hiddenSessions: Record;
};
/** SSH connection settings */
ssh?: {
diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts
index e748b821..c7a1cf05 100644
--- a/test/main/utils/jsonl.test.ts
+++ b/test/main/utils/jsonl.test.ts
@@ -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 {
diff --git a/vite.standalone.config.ts b/vite.standalone.config.ts
new file mode 100644
index 00000000..ac24a4d7
--- /dev/null
+++ b/vite.standalone.config.ts
@@ -0,0 +1,115 @@
+/**
+ * Vite build config for the standalone (non-Electron) server.
+ *
+ * Produces a single CJS bundle at dist-standalone/index.cjs that can be
+ * run with `node dist-standalone/index.cjs`.
+ */
+
+import { resolve } from 'path'
+import { defineConfig } from 'vite'
+
+import type { Plugin } from 'vite'
+
+// Node.js built-in modules that should be externalized
+const nodeBuiltins = new Set([
+ 'fs', 'path', 'os', 'events', 'stream', 'util', 'net', 'tls',
+ 'http', 'https', 'crypto', 'zlib', 'url', 'querystring',
+ 'child_process', 'buffer', 'dns', 'dgram', 'assert', 'constants',
+ 'readline', 'string_decoder', 'timers', 'tty', 'worker_threads'
+])
+
+// Packages that must be externalized because they break when bundled
+// (fastify ecosystem uses internal file resolution that doesn't survive bundling)
+const externalPackages = [
+ 'fastify', '@fastify/cors', '@fastify/static'
+]
+
+// Stub native .node addons (ssh2/cpu-features have JS fallbacks)
+function nativeModuleStub(): Plugin {
+ const STUB_ID = '\0native-stub'
+ return {
+ name: 'native-module-stub',
+ resolveId(source) {
+ if (source.endsWith('.node')) return STUB_ID
+ return null
+ },
+ load(id) {
+ if (id === STUB_ID) return 'export default {}'
+ return null
+ }
+ }
+}
+
+// Stub out Electron imports with empty modules
+const electronModules = new Set(['electron', 'electron-updater'])
+
+function electronStub(): Plugin {
+ const ELECTRON_STUB_ID = '\0electron-stub'
+ // Comprehensive stub covering all electron exports used in the codebase
+ const electronStubCode = `
+const noop = () => {};
+const noopClass = class {};
+const handler = { get: () => noop };
+const proxyObj = new Proxy({}, handler);
+export const app = proxyObj;
+export const BrowserWindow = noopClass;
+export const ipcMain = { handle: noop, on: noop, removeHandler: noop };
+export const shell = { openPath: noop, openExternal: noop };
+export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
+export const Notification = class { show() {} };
+export default proxyObj;
+`
+ return {
+ name: 'electron-stub',
+ // Use enforce: 'pre' to intercept before Vite's SSR externalization
+ enforce: 'pre',
+ resolveId(source) {
+ if (electronModules.has(source)) return ELECTRON_STUB_ID
+ return null
+ },
+ load(id) {
+ if (id === ELECTRON_STUB_ID) return electronStubCode
+ return null
+ }
+ }
+}
+
+export default defineConfig({
+ plugins: [nativeModuleStub(), electronStub()],
+ resolve: {
+ alias: {
+ '@main': resolve(__dirname, 'src/main'),
+ '@shared': resolve(__dirname, 'src/shared'),
+ '@preload': resolve(__dirname, 'src/preload')
+ }
+ },
+ ssr: {
+ // Force Vite to bundle these instead of externalizing them
+ // (SSR mode externalizes all node_modules by default)
+ noExternal: true
+ },
+ build: {
+ outDir: 'dist-standalone',
+ target: 'node20',
+ ssr: true,
+ rollupOptions: {
+ input: {
+ index: resolve(__dirname, 'src/main/standalone.ts')
+ },
+ output: {
+ format: 'cjs',
+ entryFileNames: '[name].cjs'
+ },
+ external: (id) => {
+ // Externalize Node.js built-ins
+ if (id.startsWith('node:')) return true
+ if (nodeBuiltins.has(id)) return true
+ // Externalize packages that break when bundled
+ if (externalPackages.some(pkg => id === pkg || id.startsWith(pkg + '/'))) return true
+ return false
+ }
+ },
+ minify: false,
+ sourcemap: true
+ }
+})