Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)

## Summary
Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements.

## Key Changes

**Sidebar & Navigation Redesign**
- Redesigned sessions sidebar layout with unified button primitives
- Added activity sections with project grouping and improved session organization
- Refined sidebar corners, spacing, and visual hierarchy
- Removed NavRail component in favor of streamlined sidebar
- Stabilized sessions bar toggle position in fullscreen mode

**Performance Optimizations**
- Reduced chat streaming CPU usage and storage churn
- Optimized task tool polling and live timers with debouncing
- Prevented chat state races and reduced background request load
- Debounced draft writes and coalesced session reloads
- Optimized message store updates and turn tracking

**Theme & Visual System**
- Added theme-aware window corners (desktop) and border radius tokens
- Introduced glassmorphism effects on desktop sidebar
- Added backdrop blur to UI elements

**Chat Experience**
- Added session-based permission auto-accept toggle in chat input
- Polished permission shield UX with improved icon sizing and spacing
- Fixed chat scroll-to-bottom behavior and timeline tracking
- Enhanced tool output display with better path label detection
- Removed duplicate draft context details in chat header
- Added text selection menu to chat messages

**Git Improvements**
- Refreshed git history visual design with cleaner dividers
- Added remote removal action in sync selector
- Stabilized git polling to prevent excessive requests
- Improved tool output rendering for git operations

**Settings & Panels**
- Fixed mobile scrolling on settings pages
- Made outside-click settings close instantly
- Reduced settings load churn and CPU spikes
- Improved services dropdown layout and spacing
- Softened panel resize handles

**Desktop Integration**
- Synced macOS window theme with app theme
- Restored window dragging in sidebar header zones
- Fixed system window corners on macOS
- Improved header session metadata and action controls

**Button & Component Standardization**
- Unified button primitives across all components
- Standardized destructive action patterns
- Removed unused button variants (button-large, button-small)
- Aligned context tab close hit areas

---------

Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
Bohdan Triapitsyn
2026-03-20 01:01:03 +02:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 359879153a
commit 321cc7252a
222 changed files with 8575 additions and 5456 deletions
+1 -1
View File
@@ -39,7 +39,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.2.26",
"@opencode-ai/sdk": "^1.2.27",
"@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
+2 -1
View File
@@ -113,6 +113,7 @@ function App({ apis }: AppProps) {
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
const [showCliOnboarding, setShowCliOnboarding] = React.useState(false);
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []);
const appReadyDispatchedRef = React.useRef(false);
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
@@ -534,7 +535,7 @@ function App({ apis }: AppProps) {
<FireworksProvider>
<VoiceProvider>
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
<MainLayout />
<Toaster />
<ConfigUpdateOverlay />
@@ -15,7 +15,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
import { useDeviceInfo } from '@/lib/device';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { TimelineDialog } from './TimelineDialog';
import type { PermissionRequest } from '@/types/permission';
@@ -30,6 +30,7 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
const EMPTY_QUESTIONS: QuestionRequest[] = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected';
type HydratingToolSkeletonRow = {
id: string;
@@ -193,7 +194,7 @@ export const ChatContainer: React.FC = () => {
}, [parentSession, setCurrentSession]);
const returnToParentButton = parentSession ? (
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -204,7 +205,7 @@ export const ChatContainer: React.FC = () => {
>
<RiArrowLeftLine className="h-4 w-4" />
Parent
</ButtonSmall>
</Button>
) : null;
React.useEffect(() => {
@@ -255,6 +256,7 @@ export const ChatContainer: React.FC = () => {
isPinned,
isOverflowing,
});
const { resumeToBottomInstant } = timelineController;
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
@@ -269,6 +271,26 @@ export const ChatContainer: React.FC = () => {
resumeToBottom: timelineController.resumeToBottom,
});
React.useEffect(() => {
if (typeof window === 'undefined' || !currentSessionId) {
return;
}
const handleSessionReselected = (event: Event) => {
const customEvent = event as CustomEvent<string>;
if (customEvent.detail !== currentSessionId) {
return;
}
resumeToBottomInstant();
};
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
return () => {
window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
};
}, [currentSessionId, resumeToBottomInstant]);
React.useLayoutEffect(() => {
const container = scrollRef.current;
if (!container) {
@@ -366,7 +388,7 @@ export const ChatContainer: React.FC = () => {
>
{!isDesktopExpandedInput ? (
<div className="flex-1 flex items-center justify-center">
<ChatEmptyState showDraftContext />
<ChatEmptyState />
</div>
) : null}
<div
@@ -374,7 +396,7 @@ export const ChatContainer: React.FC = () => {
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
@@ -444,7 +466,7 @@ export const ChatContainer: React.FC = () => {
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
@@ -507,7 +529,7 @@ export const ChatContainer: React.FC = () => {
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
@@ -1,12 +1,7 @@
import React from 'react';
import { RiGitBranchLine } from '@remixicon/react';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { TextLoop } from '@/components/ui/TextLoop';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
const phrases = [
"Fix the failing tests",
@@ -27,51 +22,15 @@ const phrases = [
"Add type definitions",
];
interface ChatEmptyStateProps {
showDraftContext?: boolean;
}
const ChatEmptyState: React.FC<ChatEmptyStateProps> = ({
showDraftContext = false,
}) => {
const ChatEmptyState: React.FC = () => {
const { currentTheme } = useThemeSystem();
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { setActiveDirectory, fetchStatus } = useGitStore();
const gitStatus = useGitStatus(effectiveDirectory ?? null);
// Use theme's muted foreground for secondary text
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
const branchName = typeof gitStatus?.current === 'string' && gitStatus.current.trim().length > 0
? gitStatus.current.trim()
: null;
React.useEffect(() => {
if (!showDraftContext || !effectiveDirectory) {
return;
}
setActiveDirectory(effectiveDirectory);
const state = useGitStore.getState().directories.get(effectiveDirectory);
if (!state?.status && state?.isGitRepo !== false) {
void fetchStatus(effectiveDirectory, git, { silent: true });
}
}, [effectiveDirectory, fetchStatus, git, setActiveDirectory, showDraftContext]);
return (
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
{showDraftContext && (
<div className="max-w-[calc(100%-2rem)] flex flex-col items-center gap-1">
{branchName && (
<div className="inline-flex items-center gap-1 text-body-md" style={{ color: textColor }}>
<RiGitBranchLine className="h-4 w-4 shrink-0" />
<span className="overflow-hidden whitespace-nowrap" title={branchName}>{branchName}</span>
</div>
)}
</div>
)}
<TextLoop
className="text-body-md"
interval={4}
+493 -18
View File
@@ -7,13 +7,17 @@ import {
RiCloseLine,
RiCommandLine,
RiExternalLinkLine,
RiFolderLine,
RiFullscreenLine,
RiGitPullRequestLine,
RiShieldCheckLine,
RiShieldUserLine,
RiGithubLine,
RiSendPlane2Line,
} from '@remixicon/react';
import { BrowserVoiceButton } from '@/components/voice';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionStore as useSessionManagementStore } from '@/stores/sessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
@@ -25,7 +29,7 @@ import { QueuedMessageChips } from './QueuedMessageChips';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
import { cn, isMacOS } from '@/lib/utils';
import { cn, formatDirectoryName, isMacOS } from '@/lib/utils';
import { ModelControls } from './ModelControls';
import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
@@ -49,15 +53,52 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
import { opencodeClient } from '@/lib/opencode/client';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { usePermissionStore } from '@/stores/permissionStore';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
const normalizePath = (value?: string | null): string | null => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/\\/g, '/');
if (normalized === '/') {
return '/';
}
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
};
const getProjectDisplayLabel = (project: { label?: string; path: string }): string => {
const label = project.label?.trim();
if (label) {
return label;
}
return formatDirectoryName(project.path);
};
const getProjectIconColor = (projectColor?: string | null): string | undefined => {
if (!projectColor) {
return undefined;
}
return PROJECT_COLOR_MAP[projectColor] ?? undefined;
};
const appendWithLineBreaks = (base: string, next: string): string => {
const separator = !base
@@ -154,10 +195,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
// Ref to track current message value without triggering re-renders in effects
const messageRef = React.useRef(message);
const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const skipNextDraftPersistRef = React.useRef(false);
const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map());
const currentSessionIdForDraftRef = React.useRef<string | null>(null);
const sendMessage = useSessionStore((state) => state.sendMessage);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
const newSessionDraft = useSessionStore((state) => state.newSessionDraft);
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
const setNewSessionDraftTarget = useSessionStore((state) => state.setNewSessionDraftTarget);
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
@@ -169,18 +217,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
const pendingInputText = useSessionStore((state) => state.pendingInputText);
const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts);
const currentManagementSessionId = useSessionManagementStore((state) => state.currentSessionId);
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
const agents = getVisibleAgents();
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore();
const { working } = useAssistantStatus();
const { git: runtimeGit } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
const chatSearchDirectory = useChatSearchDirectory();
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
const isDesktopExpanded = isExpandedInput && !isMobile;
const chatInputRadius = 'var(--radius-lg)';
const sendableAttachedFiles = React.useMemo(
() => attachedFiles.filter((file) => file.source !== 'server'),
@@ -423,6 +478,29 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
messageRef.current = message;
}, [message]);
React.useEffect(() => {
currentSessionIdForDraftRef.current = currentSessionId;
}, [currentSessionId]);
const persistDraftImmediately = React.useCallback((sessionId: string | null, draft: string) => {
const key = getDraftKey(sessionId);
const lastPersisted = lastPersistedDraftRef.current.get(key);
if (lastPersisted === draft) {
return;
}
saveStoredDraft(sessionId, draft);
lastPersistedDraftRef.current.set(key, draft);
}, []);
const clearPendingDraftPersist = React.useCallback(() => {
if (!draftPersistTimerRef.current) {
return;
}
clearTimeout(draftPersistTimerRef.current);
draftPersistTimerRef.current = null;
}, []);
// Handle initial draft restoration and text selection
const hasHandledInitialDraftRef = React.useRef(false);
React.useEffect(() => {
@@ -455,10 +533,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const oldSessionId = prevSessionIdRef.current;
prevSessionIdRef.current = currentSessionId;
setInputMode('normal');
clearPendingDraftPersist();
skipNextDraftPersistRef.current = true;
if (persistChatDraft) {
// Save current draft for the session we're leaving
saveStoredDraft(oldSessionId, messageRef.current);
persistDraftImmediately(oldSessionId, messageRef.current);
// Restore draft for the session we're entering
const newDraft = getStoredDraft(currentSessionId);
setMessage(newDraft);
@@ -472,7 +552,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
setMessage('');
}
}
}, [currentSessionId, persistChatDraft]);
}, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]);
// Focus textarea when new session draft is opened
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
@@ -494,16 +574,37 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// Persist chat input draft to localStorage per session (only if setting enabled)
React.useEffect(() => {
if (!persistChatDraft) {
// Clear stored draft for current session when setting is disabled
try {
localStorage.removeItem(getDraftKey(currentSessionId));
} catch {
// Ignore
}
clearPendingDraftPersist();
persistDraftImmediately(currentSessionId, '');
return;
}
saveStoredDraft(currentSessionId, message);
}, [message, persistChatDraft, currentSessionId]);
if (skipNextDraftPersistRef.current) {
skipNextDraftPersistRef.current = false;
return;
}
clearPendingDraftPersist();
const draftSnapshot = message;
const sessionSnapshot = currentSessionId;
draftPersistTimerRef.current = setTimeout(() => {
draftPersistTimerRef.current = null;
persistDraftImmediately(sessionSnapshot, draftSnapshot);
}, CHAT_DRAFT_PERSIST_DEBOUNCE_MS);
return () => {
clearPendingDraftPersist();
};
}, [clearPendingDraftPersist, currentSessionId, message, persistChatDraft, persistDraftImmediately]);
React.useEffect(() => {
return () => {
clearPendingDraftPersist();
if (persistChatDraft) {
persistDraftImmediately(currentSessionIdForDraftRef.current, messageRef.current);
}
};
}, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]);
// Session activity for queue availability and controls
const { phase: sessionPhase } = useCurrentSessionActivity();
@@ -2164,6 +2265,243 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const footerGapClass = 'gap-x-1.5 gap-y-0';
const isVSCode = isVSCodeRuntime();
const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode;
const selectedDraftProject = React.useMemo(() => {
const explicit = newSessionDraft?.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null;
if (explicit) {
return explicit;
}
const active = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: null;
if (active) {
return active;
}
return projects[0] ?? null;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
const selectedDraftProjectPath = React.useMemo(
() => normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.path],
);
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
React.useEffect(() => {
if (!showDraftTargetSelectors || !selectedDraftProjectPath || !selectedDraftProject || !runtimeGit) {
setIsDiscoveringDraftBranches(false);
return;
}
if (selectedDraftProjectBranches?.all) {
setIsDiscoveringDraftBranches(false);
return;
}
let cancelled = false;
setIsDiscoveringDraftBranches(true);
void fetchBranches(selectedDraftProjectPath, runtimeGit)
.finally(() => {
if (!cancelled) {
setIsDiscoveringDraftBranches(false);
}
});
return () => {
cancelled = true;
};
}, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranches?.all, selectedDraftProjectPath, showDraftTargetSelectors]);
const projectRootBranchOption = React.useMemo(() => {
if (!selectedDraftProject) {
return null;
}
const value = normalizePath(selectedDraftProject.path);
if (!value) {
return null;
}
const projectRootBranch = selectedDraftProjectBranches?.current?.trim() ?? '';
if (!projectRootBranch) {
return null;
}
return {
value,
label: projectRootBranch,
};
}, [selectedDraftProject, selectedDraftProjectBranches]);
const worktreeBranchOptions = React.useMemo(() => {
if (!selectedDraftProject) {
return [] as Array<{ value: string; label: string }>;
}
const seen = new Set<string>();
const options: Array<{ value: string; label: string }> = [];
const rootValue = projectRootBranchOption?.value ?? null;
const worktrees = (() => {
if (!selectedDraftProjectPath) {
return [];
}
return availableWorktreesByProject.get(selectedDraftProjectPath)
?? availableWorktreesByProject.get(selectedDraftProject.path)
?? [];
})();
worktrees
.slice()
.sort((a, b) => a.branch.localeCompare(b.branch))
.forEach((worktree) => {
const normalizedValue = normalizePath(worktree.path);
if (!normalizedValue || normalizedValue === rootValue || seen.has(normalizedValue)) {
return;
}
seen.add(normalizedValue);
options.push({
value: normalizedValue,
label: worktree.branch?.trim() || formatDirectoryName(worktree.path),
});
});
return options;
}, [availableWorktreesByProject, projectRootBranchOption?.value, selectedDraftProject, selectedDraftProjectPath]);
const selectedDraftDirectory = React.useMemo(
() => normalizePath(newSessionDraft?.directoryOverride ?? null) ?? selectedDraftProjectPath,
[newSessionDraft?.directoryOverride, selectedDraftProjectPath],
);
const draftBranchItems = React.useMemo(() => {
const baseItems: Array<{ value: string; label: string }> = [];
if (projectRootBranchOption) {
baseItems.push(projectRootBranchOption);
}
baseItems.push(...worktreeBranchOptions);
if (!selectedDraftDirectory) {
return baseItems;
}
if (baseItems.some((option) => option.value === selectedDraftDirectory)) {
return baseItems;
}
return [
...baseItems,
{ value: selectedDraftDirectory, label: formatDirectoryName(selectedDraftDirectory) },
];
}, [projectRootBranchOption, selectedDraftDirectory, worktreeBranchOptions]);
const selectedDraftBranchLabel = React.useMemo(() => {
const selectedValue = selectedDraftDirectory ?? draftBranchItems[0]?.value ?? null;
if (!selectedValue) {
return null;
}
return draftBranchItems.find((item) => item.value === selectedValue)?.label ?? formatDirectoryName(selectedValue);
}, [draftBranchItems, selectedDraftDirectory]);
const selectedDraftBranchIsKnown = React.useMemo(() => {
if (!selectedDraftDirectory) {
return true;
}
if (projectRootBranchOption?.value === selectedDraftDirectory) {
return true;
}
return worktreeBranchOptions.some((option) => option.value === selectedDraftDirectory);
}, [projectRootBranchOption?.value, selectedDraftDirectory, worktreeBranchOptions]);
const shouldShowDraftBranchSelector = React.useMemo(() => {
if (isDiscoveringDraftBranches) {
return false;
}
if (projectRootBranchOption) {
return true;
}
return worktreeBranchOptions.length > 0;
}, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]);
const handleDraftProjectChange = React.useCallback((projectId: string) => {
const project = projects.find((entry) => entry.id === projectId);
if (!project) {
return;
}
if (activeProjectId !== projectId) {
setActiveProjectIdOnly(projectId);
}
setNewSessionDraftTarget({
projectId,
directoryOverride: project.path,
});
}, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]);
const handleDraftDirectoryChange = React.useCallback((directory: string) => {
if (!selectedDraftProject) {
return;
}
setNewSessionDraftTarget({
projectId: selectedDraftProject.id,
directoryOverride: directory,
});
}, [selectedDraftProject, setNewSessionDraftTarget]);
const renderProjectLabelWithIcon = React.useCallback((project: {
id: string;
path: string;
label?: string;
icon?: string | null;
color?: string | null;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
iconBackground?: string | null;
}) => {
const imageUrl = getProjectIconImageUrl(
{ id: project.id, iconImage: project.iconImage ?? null },
{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
},
);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color);
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{imageUrl ? (
<span
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
</span>
) : ProjectIcon ? (
<ProjectIcon className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<RiFolderLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
)}
<span className="truncate">{getProjectDisplayLabel(project)}</span>
</span>
);
}, [currentTheme.colors.surface.foreground, currentTheme.metadata.variant]);
React.useEffect(() => {
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
return;
}
const valid = draftBranchItems.some((option) => option.value === selectedDraftDirectory);
if (valid) {
return;
}
setNewSessionDraftTarget({
projectId: selectedDraftProject.id,
directoryOverride: selectedDraftProject.path,
});
}, [draftBranchItems, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]);
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4');
@@ -2172,6 +2510,73 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed';
const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId;
const permissionAutoAcceptEnabled = usePermissionStore((state) => {
if (!permissionScopeSessionId) {
return false;
}
return state.isSessionAutoAccepting(permissionScopeSessionId);
});
const handlePermissionAutoAcceptToggle = React.useCallback(() => {
if (!permissionScopeSessionId) {
toast.error('Open a session first');
return;
}
const nextEnabled = !permissionAutoAcceptEnabled;
setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => {
toast.error('Failed to toggle permission auto-accept');
});
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
const permissionAutoAcceptAriaLabel = permissionAutoAcceptEnabled
? 'Disable permission auto-accept'
: 'Enable permission auto-accept';
const permissionAutoAcceptTooltipLabel = permissionAutoAcceptEnabled
? 'Permission auto-accept: on'
: 'Permission auto-accept: off';
const permissionAutoAcceptButton = (
<button
type="button"
onClick={handlePermissionAutoAcceptToggle}
className={cn(
footerIconButtonClass,
'rounded-md hover:bg-transparent',
!permissionScopeSessionId && 'opacity-30',
)}
onMouseDown={(event) => {
event.preventDefault();
}}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
event.stopPropagation();
}
}}
aria-pressed={permissionAutoAcceptEnabled}
aria-label={permissionAutoAcceptAriaLabel}
title={permissionAutoAcceptAriaLabel}
>
{permissionAutoAcceptEnabled ? (
<RiShieldCheckLine className={cn(iconSizeClass)} style={{ color: 'var(--status-info)' }} />
) : (
<RiShieldUserLine className={cn(iconSizeClass)} />
)}
</button>
);
const permissionAutoAcceptButtonWithTooltip = (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
{permissionAutoAcceptButton}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
{permissionAutoAcceptTooltipLabel}
</TooltipContent>
</Tooltip>
);
// Send button - respects queue mode setting
const sendButton = (
@@ -2536,6 +2941,74 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
showAssistantStatus={false}
showTodos
/>
{showDraftTargetSelectors && selectedDraftProject ? (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
<Select
value={selectedDraftProject.id}
onValueChange={handleDraftProjectChange}
>
<SelectTrigger
size="sm"
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[state=open]:bg-transparent"
>
<SelectValue>
{renderProjectLabelWithIcon(selectedDraftProject)}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
{renderProjectLabelWithIcon(project)}
</SelectItem>
))}
</SelectContent>
</Select>
{shouldShowDraftBranchSelector ? (
<Select
value={selectedDraftDirectory ?? draftBranchItems[0]?.value ?? normalizePath(selectedDraftProject.path) ?? ''}
onValueChange={handleDraftDirectoryChange}
>
<SelectTrigger
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[state=open]:bg-transparent"
>
<SelectValue>
{selectedDraftBranchLabel ?? 'Branch'}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>Project root</SelectLabel>
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
{projectRootBranchOption.label}
</SelectItem>
</SelectGroup>
) : null}
{worktreeBranchOptions.length > 0 ? (
<>
{projectRootBranchOption ? <SelectSeparator /> : null}
<SelectGroup>
<SelectLabel>Worktrees</SelectLabel>
{worktreeBranchOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
) : null}
{selectedDraftDirectory && !selectedDraftBranchIsKnown ? (
<SelectItem value={selectedDraftDirectory} className="max-w-[24rem] truncate">
{selectedDraftBranchLabel}
</SelectItem>
) : null}
</SelectContent>
</Select>
) : null}
</div>
) : null}
<div
className={cn(
"flex flex-col relative overflow-visible",
@@ -2548,7 +3021,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
isDragging && "ring-2 ring-primary ring-offset-2"
)}
style={{
borderRadius: cornerRadius,
borderRadius: chatInputRadius,
backgroundColor: currentTheme?.colors?.surface?.subtle,
}}
ref={dropZoneRef}
@@ -2714,8 +3187,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
flex: isDesktopExpanded ? '1 1 auto' : 'none',
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
borderTopLeftRadius: chatInputRadius,
borderTopRightRadius: chatInputRadius,
}}
rows={1}
/>
@@ -2727,16 +3200,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass)
)}
style={{
borderBottomLeftRadius: cornerRadius,
borderBottomRightRadius: cornerRadius,
borderBottomLeftRadius: chatInputRadius,
borderBottomRightRadius: chatInputRadius,
}}
data-chat-input-footer="true"
>
{isMobile ? (
<>
<div className="flex w-full items-center justify-between gap-x-1.5">
<div className="flex items-center gap-x-1">
<div className="flex items-center gap-x-1.5">
{attachmentsControls}
{permissionAutoAcceptButton}
</div>
<div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
@@ -2801,6 +3275,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</div>
</TooltipContent>
</Tooltip>
{permissionAutoAcceptButtonWithTooltip}
</div>
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
@@ -971,6 +971,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const assistantTopPaddingClass = !isUser && shouldShowHeader
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
: 'pt-0';
const userMessageRadius = 'var(--radius-lg)';
return (
<>
@@ -995,7 +996,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className="max-w-[85%]">
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="rounded-[var(--radius-xl)] rounded-br-[var(--radius-sm)] px-5 py-3 shadow-none border border-primary/5">
<div
style={{
backgroundColor: 'var(--chat-user-message-bg)',
borderRadius: userMessageRadius,
borderBottomRightRadius: 'var(--radius-sm)',
}}
className="px-5 py-3 shadow-none border border-primary/5"
>
<MessageBody
messageId={message.info.id}
parts={displayParts}
@@ -971,12 +971,14 @@ const useFileReferenceInteractions = ({
readFile,
editor,
preferRuntimeEditor,
deferValidationUntilIdle = false,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
effectiveDirectory: string;
readFile?: (path: string) => Promise<{ content: string; path: string }>;
editor?: EditorAPI;
preferRuntimeEditor?: boolean;
deferValidationUntilIdle?: boolean;
}) => {
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
@@ -1048,6 +1050,9 @@ const useFileReferenceInteractions = ({
};
const runValidationSweep = async (paths: string[], expectedPassID: number) => {
if (deferValidationUntilIdle) {
return;
}
if (isValidationSweepRunningRef.current || paths.length === 0) {
return;
}
@@ -1208,9 +1213,13 @@ const useFileReferenceInteractions = ({
annotationDebounceRef.current = window.setTimeout(() => {
annotationDebounceRef.current = null;
void annotateFileLinks();
}, 120);
}, deferValidationUntilIdle ? 280 : 120);
});
observer.observe(container, {
childList: true,
subtree: true,
...(deferValidationUntilIdle ? {} : { characterData: true }),
});
observer.observe(container, { childList: true, subtree: true, characterData: true });
container.addEventListener('click', handleClick);
container.addEventListener('keydown', handleKeyDown);
@@ -1226,7 +1235,7 @@ const useFileReferenceInteractions = ({
container.removeEventListener('click', handleClick);
container.removeEventListener('keydown', handleKeyDown);
};
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
}, [containerRef, deferValidationUntilIdle, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
};
const useMermaidInlineInteractions = ({
@@ -1345,6 +1354,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
readFile: files.readFile,
editor,
preferRuntimeEditor: runtime.isVSCode,
deferValidationUntilIdle: isStreaming,
});
const shikiThemes = useMarkdownShikiThemes();
@@ -1358,10 +1368,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
? 'streamdown-content streamdown-reasoning'
: 'streamdown-content';
const streamdownAnimated = React.useMemo(
() => ({ animation: 'blurIn' as const, duration: 150, easing: 'ease-out' }),
[],
);
const streamdownAnimated = undefined;
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={streamdownContainerRef}>
+92 -17
View File
@@ -318,6 +318,33 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
};
};
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
const cached = normalizedMessageBySource.get(message);
if (cached) {
return cached;
}
const filteredParts = filterSyntheticParts(message.parts);
const normalized = filteredParts === message.parts
? message
: {
...message,
parts: filteredParts,
};
normalizedMessageBySource.set(message, normalized);
return normalized;
};
const isAssistantTextOnlyMessage = (message: ChatMessageEntry): boolean => {
if (resolveMessageRole(message) !== 'assistant') {
return false;
}
return message.parts.length > 0 && message.parts.every((part) => part?.type === 'text');
};
interface MessageListProps {
sessionKey: string;
turnStart: number;
@@ -813,6 +840,11 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
previousOrder: string[];
animatedIds: Set<string>;
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
const baseDisplayCacheRef = React.useRef<{
input: ChatMessageEntry[];
output: ChatMessageEntry[];
outputIndexById: Map<string, number>;
} | null>(null);
const stableOnMessageContentChange = useStableEvent(onMessageContentChange);
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
@@ -843,8 +875,51 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const baseDisplayMessages = React.useMemo(() => {
const seenIdsFromTail = new Set<string>();
const cached = baseDisplayCacheRef.current;
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
const canUseTailFastPath = Boolean(lastMessage && isAssistantTextOnlyMessage(lastMessage));
if (cached && canUseTailFastPath && cached.input.length === messages.length && messages.length > 0) {
let changedCount = 0;
let changedIndex = -1;
let idsStable = true;
for (let index = 0; index < messages.length; index += 1) {
if (messages[index]?.info?.id !== cached.input[index]?.info?.id) {
idsStable = false;
break;
}
if (messages[index] !== cached.input[index]) {
changedCount += 1;
changedIndex = index;
if (changedCount > 1) {
break;
}
}
}
if (idsStable && changedCount === 1 && changedIndex === messages.length - 1) {
const changedMessage = messages[changedIndex];
const previousMessage = changedIndex > 0 ? messages[changedIndex - 1] : undefined;
const bridgeSensitive = isUserSubtaskMessage(previousMessage) || isUserShellMarkerMessage(previousMessage);
if (changedMessage && isAssistantTextOnlyMessage(changedMessage) && !bridgeSensitive) {
const outputIndex = cached.outputIndexById.get(changedMessage.info.id);
if (outputIndex !== undefined) {
const nextOutput = [...cached.output];
nextOutput[outputIndex] = getNormalizedMessageForDisplay(changedMessage);
baseDisplayCacheRef.current = {
input: messages,
output: nextOutput,
outputIndexById: cached.outputIndexById,
};
return nextOutput;
}
}
}
}
const seenIdsFromTail = new Set<string>();
const dedupedMessages: ChatMessageEntry[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
@@ -855,26 +930,13 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}
seenIdsFromTail.add(messageId);
}
dedupedMessages.push(message);
dedupedMessages.push(getNormalizedMessageForDisplay(message));
}
dedupedMessages.reverse();
const normalizedMessages = dedupedMessages
.map((message) => {
const filteredParts = filterSyntheticParts(message.parts);
const normalized = filteredParts === message.parts
? message
: {
...message,
parts: filteredParts,
};
return normalized;
});
const output: ChatMessageEntry[] = [];
for (let index = 0; index < normalizedMessages.length; index += 1) {
const current = normalizedMessages[index];
for (let index = 0; index < dedupedMessages.length; index += 1) {
const current = dedupedMessages[index];
const previous = output.length > 0 ? output[output.length - 1] : undefined;
if (isUserSubtaskMessage(previous)) {
@@ -896,6 +958,19 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
output.push(current);
}
const outputIndexById = new Map<string, number>();
output.forEach((message, index) => {
const id = message.info?.id;
if (typeof id === 'string' && id.length > 0) {
outputIndexById.set(id, index);
}
});
baseDisplayCacheRef.current = {
input: messages,
output,
outputIndexById,
};
return output;
}, [messages]);
@@ -37,7 +37,6 @@ import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Switch } from '@/components/ui/switch';
import { TextLoop } from '@/components/ui/TextLoop';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
@@ -350,14 +349,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
: currentAgentName;
const sessionIdForEditMode = currentSessionId ?? '__global__';
const sessionEditMode = useContextStore((state) => {
if (!uiAgentName) {
return undefined;
}
return state.getSessionAgentEditMode(sessionIdForEditMode, uiAgentName, 'ask');
});
const setSessionAgentEditMode = useContextStore((state) => state.setSessionAgentEditMode);
const {
toggleFavoriteModel,
isFavoriteModel,
@@ -506,21 +497,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return getCurrentAgent?.();
}, [agents, getCurrentAgent, uiAgentName]);
const agentEditAction = React.useMemo<PermissionAction>(() => {
if (!currentAgent) {
return 'deny';
}
return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow';
}, [currentAgent]);
const selectionContextReady = Boolean(uiAgentName);
const approveEditsAvailable = agentEditAction === 'ask';
const approveEditsChecked = approveEditsAvailable
? sessionEditMode === 'allow' || sessionEditMode === 'full'
: agentEditAction === 'allow';
const approveEditsDisabled = !selectionContextReady || !approveEditsAvailable;
const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default';
const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8';
const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
@@ -545,13 +521,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return <RiQuestionLine className={combinedClassName} style={iconStyle} />;
}, [editToggleIconClass]);
const handleApproveEditsToggle = React.useCallback((checked: boolean) => {
if (!selectionContextReady || !currentAgentName || !approveEditsAvailable) {
return;
}
setSessionAgentEditMode(sessionIdForEditMode, currentAgentName, checked ? 'allow' : 'ask', 'ask');
}, [approveEditsAvailable, currentAgentName, selectionContextReady, setSessionAgentEditMode, sessionIdForEditMode]);
const currentProvider = getCurrentProvider();
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
@@ -1819,23 +1788,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
onClose={closeMobilePanel}
title="Select agent"
contentMaxHeightClassName="max-h-[min(52dvh,360px)]"
footer={(
<div className="flex items-center justify-between">
<span
className={cn(
'typography-meta font-medium',
approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
)}
>
Auto-approve edits
</span>
<Switch
checked={approveEditsChecked}
disabled={approveEditsDisabled}
onCheckedChange={handleApproveEditsToggle}
/>
</div>
)}
>
<div className="flex flex-col gap-2">
{selectableDesktopAgents.map((agent) => {
@@ -2302,10 +2254,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{/* Favorites Section */}
{filteredFavorites.length > 0 && (
<>
<div>
<DropdownMenuLabel
style={{ backgroundColor: 'var(--surface-elevated)' }}
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30"
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30"
>
<RiStarFill className="h-4 w-4 text-primary" />
Favorites
@@ -2314,16 +2265,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const idx = currentFlatIndex++;
return renderModelRow(model, providerID, modelID, 'fav', idx, modelSelectedIndex === idx);
})}
</>
</div>
)}
{/* Recents Section */}
{filteredRecents.length > 0 && (
<>
<div>
{filteredFavorites.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel
style={{ backgroundColor: 'var(--surface-elevated)' }}
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30"
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30"
>
<RiTimeLine className="h-4 w-4" />
Recent
@@ -2332,7 +2282,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const idx = currentFlatIndex++;
return renderModelRow(model, providerID, modelID, 'recent', idx, modelSelectedIndex === idx);
})}
</>
</div>
)}
{/* Separator before providers */}
@@ -2342,7 +2292,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{/* All Providers - Flat List */}
{providerSections.map(({ provider, isExpanded, visibleModels }, index) => (
<React.Fragment key={provider.id}>
<div key={provider.id}>
{index > 0 && <DropdownMenuSeparator />}
<div
role="button"
@@ -2366,8 +2316,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
}}
className={cn(
'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30',
'bg-[var(--surface-elevated)] text-left transition-colors',
'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30',
'text-left transition-colors',
forceExpandProviders ? 'cursor-default' : 'cursor-pointer'
)}
aria-expanded={isExpanded}
@@ -2392,7 +2342,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const idx = currentFlatIndex++;
return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === idx);
})}
</React.Fragment>
</div>
))}
</div>
</ScrollableOverlay>
@@ -2755,24 +2705,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)}
</div>
</ScrollableOverlay>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1 px-1 py-0.5">
<div className="rounded-xl bg-transparent">
<div className="flex items-center justify-between px-2 py-2">
<span className={cn(
'typography-meta font-medium',
approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
)}>
Auto-approve edits
</span>
<Switch
checked={approveEditsChecked}
disabled={approveEditsDisabled}
onCheckedChange={handleApproveEditsToggle}
/>
</div>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
{renderAgentTooltipContent()}
@@ -51,6 +51,7 @@ export interface UseChatTimelineControllerResult {
loadEarlier: () => Promise<void>;
revealBufferedTurns: () => Promise<boolean>;
resumeToBottom: () => void;
resumeToBottomInstant: () => void;
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
captureViewportAnchor: () => ViewportAnchor | null;
@@ -394,6 +395,14 @@ export const useChatTimelineController = ({
scrollToBottom({ force: true });
}, [scrollToBottom]);
const resumeToBottomInstant = React.useCallback(() => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setTurnStart(nextStart);
setPendingRevealWork(false);
setIsLoadingOlder(false);
scrollToBottom({ instant: true, force: true });
}, [scrollToBottom]);
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
setActiveTurnId(turnId);
}, []);
@@ -411,6 +420,7 @@ export const useChatTimelineController = ({
loadEarlier,
revealBufferedTurns,
resumeToBottom,
resumeToBottomInstant,
scrollToTurn,
scrollToMessage,
captureViewportAnchor,
@@ -18,11 +18,30 @@ export const collectVisibleSessionIdsForBlockingRequests = (
return [];
}
const childIds = sessions
.filter((session) => session.parentID === currentSessionId)
.map((session) => session.id);
const childrenByParent = new Map<string, string[]>();
for (const session of sessions) {
if (!session.parentID) {
continue;
}
const existing = childrenByParent.get(session.parentID) ?? [];
existing.push(session.id);
childrenByParent.set(session.parentID, existing);
}
return [currentSessionId, ...childIds];
const scoped = [currentSessionId];
const seen = new Set(scoped);
for (const sessionId of scoped) {
const children = childrenByParent.get(sessionId) ?? [];
for (const childId of children) {
if (seen.has(childId)) {
continue;
}
seen.add(childId);
scoped.push(childId);
}
}
return scoped;
};
export const flattenBlockingRequests = <T extends { id: string }>(
@@ -80,6 +80,7 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
let ro: ResizeObserver | undefined;
let mo: MutationObserver | undefined;
let frame: number | undefined;
let roDebounce: ReturnType<typeof setTimeout> | undefined;
let active: string | undefined;
let dirty = true;
@@ -198,12 +199,17 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
}
}
clearTimeout(roDebounce);
roDebounce = undefined;
ro?.disconnect();
ro = undefined;
if (CtorRO) {
ro = new CtorRO(() => {
dirty = true;
schedule();
clearTimeout(roDebounce);
roDebounce = setTimeout(() => {
dirty = true;
schedule();
}, 100);
});
ro.observe(container);
for (const element of nodes.values()) {
@@ -218,7 +224,15 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
dirty = true;
schedule();
});
mo.observe(container, { subtree: true, childList: true, characterData: true });
const moConfig: MutationObserverInit = {
subtree: true,
childList: true,
};
if (!CtorRO) {
moConfig.characterData = true;
moConfig.characterDataOldValue = false;
}
mo.observe(container, moConfig);
}
dirty = true;
@@ -292,6 +306,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
caf(frame);
}
frame = undefined;
clearTimeout(roDebounce);
roDebounce = undefined;
clear();
io?.disconnect();
ro?.disconnect();
@@ -81,69 +81,8 @@ interface ProjectActivityResult {
export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivityResult => {
const activityParts: TurnActivityRecord[] = [];
const hasTools = input.assistantMessages.some((message) =>
message.parts.some((part) => part.type === 'tool')
);
const hasReasoning = input.assistantMessages.some((message) =>
message.parts.some((part) => part.type === 'reasoning' && Boolean(getPartText(part)))
);
input.assistantMessages.forEach((message) => {
message.parts.forEach((part, partIndex) => {
if (part.type === 'tool') {
activityParts.push({
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
kind: 'tool',
});
return;
}
if (part.type === 'reasoning') {
const text = getPartText(part);
if (!text) {
return;
}
activityParts.push({
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
kind: 'reasoning',
});
return;
}
if (!input.showTextJustificationActivity || part.type !== 'text') {
return;
}
if (!isAssistantMessageCompleted(message)) {
return;
}
const finish = getMessageFinish(message);
if (!finish) {
return;
}
if (finish === 'stop') {
return;
}
const text = getPartText(part);
if (!text) {
return;
}
activityParts.push({
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
kind: 'justification',
});
});
});
const activitySegments: TurnActivityGroup[] = [];
const activityByPart = new WeakMap<object, TurnActivityRecord>();
activityParts.forEach((activity) => {
activityByPart.set(activity.part as object, activity);
});
let hasTools = false;
let hasReasoning = false;
const taskMessageById = new Map<string, string>();
const taskOrder: string[] = [];
@@ -151,32 +90,66 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
let currentAfterToolPartId: string | null = null;
input.assistantMessages.forEach((message) => {
const messageId = message.info.id;
const messageCompleted = isAssistantMessageCompleted(message);
const finish = getMessageFinish(message);
message.parts.forEach((part, partIndex) => {
if (part.type === 'tool') {
const toolName = (part as { tool?: unknown }).tool;
if (isStandaloneTool(toolName)) {
const toolPartId = part.id ?? `${messageId}-part-${partIndex}-${part.type}`;
if (!taskMessageById.has(toolPartId)) {
taskMessageById.set(toolPartId, messageId);
taskOrder.push(toolPartId);
}
currentAfterToolPartId = toolPartId;
return;
}
const isTool = part.type === 'tool';
if (isTool) {
hasTools = true;
}
const activity = activityByPart.get(part as object);
if (!activity) {
const text = part.type === 'reasoning' || part.type === 'text'
? getPartText(part)
: undefined;
if (part.type === 'reasoning' && text) {
hasReasoning = true;
}
const toolName = isTool
? (part as { tool?: unknown }).tool
: undefined;
const standaloneTool = isTool && isStandaloneTool(toolName);
if (standaloneTool) {
const toolPartId = part.id ?? `${message.info.id}-part-${partIndex}-${part.type}`;
if (!taskMessageById.has(toolPartId)) {
taskMessageById.set(toolPartId, message.info.id);
taskOrder.push(toolPartId);
}
currentAfterToolPartId = toolPartId;
}
let kind: TurnActivityRecord['kind'] | null = null;
if (isTool) {
kind = 'tool';
} else if (part.type === 'reasoning') {
if (text) {
kind = 'reasoning';
}
} else if (
input.showTextJustificationActivity
&& part.type === 'text'
&& messageCompleted
&& typeof finish === 'string'
&& finish !== 'stop'
&& text
) {
kind = 'justification';
}
if (!kind) {
return;
}
if (activity.kind === 'tool') {
const toolName = (activity.part as { tool?: unknown }).tool;
if (isStandaloneTool(toolName)) {
return;
}
const activity: TurnActivityRecord = {
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
kind,
};
activityParts.push(activity);
if (kind === 'tool' && standaloneTool) {
return;
}
const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
@@ -185,6 +158,8 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
});
});
const activitySegments: TurnActivityGroup[] = [];
const pickStartAnchor = (segmentParts: TurnActivityRecord[]): string | undefined => {
if (segmentParts.length === 0) {
return undefined;
@@ -24,6 +24,12 @@ interface SelectionPayload {
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
const BLOCK_TAGS = new Set([
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
'section', 'table', 'ul',
]);
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
@@ -103,6 +109,11 @@ const renderBlockMarkdownNode = (node: Node): string => {
return `\`\`\`${language}\n${code}\n\`\`\``;
}
if (tag === 'code') {
const code = normalizeLineBreaks(element.textContent || '').trim();
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
}
if (tag === 'ul') return renderListMarkdown(element, false);
if (tag === 'ol') return renderListMarkdown(element, true);
@@ -137,8 +148,34 @@ const renderBlockMarkdownNode = (node: Node): string => {
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
};
const isInlineSelectionFragment = (fragment: DocumentFragment): boolean => {
return Array.from(fragment.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) {
return true;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return true;
}
const element = node as HTMLElement;
return !BLOCK_TAGS.has(element.tagName.toLowerCase());
});
};
const rangeToMarkdown = (range: Range, plainText: string): string => {
const fragment = range.cloneContents();
if (isInlineSelectionFragment(fragment)) {
const inlineMarkdown = trimSelectionValue(
Array.from(fragment.childNodes)
.map((node) => renderInlineMarkdownNode(node))
.join('')
);
if (inlineMarkdown) {
return inlineMarkdown;
}
}
const markdown = Array.from(fragment.childNodes)
.map((node) => renderBlockMarkdownNode(node))
.filter((value) => value.length > 0)
@@ -436,6 +473,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
)}
style={{
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
}}
>
<button
@@ -507,6 +546,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
style={{
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
}}
>
<button
onClick={handleAddToChat}
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useUIStore } from '@/stores/useUIStore';
import { useDurationTickerNow } from './useDurationTicker';
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
@@ -65,19 +66,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()):
};
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (!active) {
return;
}
const timer = window.setInterval(() => {
setNow(Date.now());
}, 100);
return () => window.clearInterval(timer);
}, [active]);
const now = useDurationTickerNow(active, 250);
return <>{formatDuration(start, end, now)}</>;
};
@@ -13,6 +13,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionActivity } from '@/hooks/useSessionActivity';
import { opencodeClient } from '@/lib/opencode/client';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Text } from '@/components/ui/text';
@@ -32,6 +33,7 @@ import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
import { MinDurationShineText } from './MinDurationShineText';
import { ToolRevealOnMount } from './ToolRevealOnMount';
import { getToolIcon } from './toolPresentation';
import { useDurationTickerNow } from './useDurationTicker';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -144,6 +146,14 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
};
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
const TASK_TOOL_POLL_FAST_MS = 1200;
const TASK_TOOL_POLL_IDLE_MS = 3200;
const TASK_TOOL_POLL_HIDDEN_MS = 6000;
const TASK_TOOL_INITIAL_FETCH_LIMIT = 500;
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
const duration = Math.min(Math.max(0, (end ?? now) - start), MAX_DURATION_MS);
@@ -154,17 +164,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()) =
};
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (!active) {
return;
}
const timer = window.setInterval(() => {
setNow(Date.now());
}, 100);
return () => window.clearInterval(timer);
}, [active]);
const now = useDurationTickerNow(active, 250);
return <>{formatDuration(start, end, now)}</>;
};
@@ -642,6 +642,41 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[])
return entries;
};
const buildTaskSessionMessagesSignature = (messages: SessionMessageWithParts[]): string => {
if (!Array.isArray(messages) || messages.length === 0) {
return '0';
}
const lastMessage = messages[messages.length - 1];
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
const lastMessageUpdated =
typeof lastMessage?.info?.time?.completed === 'number'
? lastMessage.info.time.completed
: typeof lastMessage?.info?.time?.created === 'number'
? lastMessage.info.time.created
: 0;
const lastParts = Array.isArray(lastMessage?.parts) ? lastMessage.parts : [];
const lastPart = lastParts[lastParts.length - 1] as Record<string, unknown> | undefined;
const tailType = typeof lastPart?.type === 'string' ? lastPart.type : '';
const tailId = typeof lastPart?.id === 'string' ? lastPart.id : '';
const tailTextLength = (() => {
const textCandidate = lastPart?.text;
if (typeof textCandidate === 'string') {
return textCandidate.length;
}
const stateCandidate = lastPart?.state;
if (stateCandidate && typeof stateCandidate === 'object') {
const stateStatus = (stateCandidate as Record<string, unknown>).status;
if (typeof stateStatus === 'string') {
return stateStatus.length;
}
}
return 0;
})();
return `${messages.length}:${lastMessageId}:${lastMessageUpdated}:${lastParts.length}:${tailType}:${tailId}:${tailTextLength}`;
};
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
const title = entry.state?.title;
if (typeof title === 'string' && title.trim().length > 0) {
@@ -687,7 +722,16 @@ const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
return false;
}
return trimmed.includes('/') || trimmed.includes('\\');
if (trimmed.includes('/') || trimmed.includes('\\')) {
return true;
}
const baseName = trimmed.split(/[\\/]/).pop() || trimmed;
if (baseName.startsWith('.') || baseName.includes('.')) {
return true;
}
return /^[A-Za-z0-9_-]+$/.test(baseName);
};
const stripTaskMetadataFromOutput = (output: string): string => {
@@ -1692,6 +1736,69 @@ const ToolPart: React.FC<ToolPartProps> = ({
return false;
}, [childSessionMessages, isTaskTool, taskSessionId]);
const childSessionActivity = useSessionActivity(taskSessionId);
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
const taskPollNoChangeCountRef = React.useRef(0);
const taskPollLastSignatureRef = React.useRef<string>('');
React.useEffect(() => {
setTaskChildSeenActive(false);
setTaskChildPollingStopped(false);
taskPollNoChangeCountRef.current = 0;
taskPollLastSignatureRef.current = '';
}, [taskSessionId]);
React.useEffect(() => {
if (!isTaskTool || !taskSessionId) {
return;
}
const childSessionIsActive =
childSessionActivity.phase === 'busy'
|| childSessionActivity.phase === 'retry'
|| childSessionHasInFlightTools
|| (!isFinalized && activeLatched);
if (childSessionIsActive) {
if (!taskChildSeenActive) {
setTaskChildSeenActive(true);
}
if (taskChildPollingStopped) {
setTaskChildPollingStopped(false);
}
return;
}
if (!taskChildSeenActive || taskChildPollingStopped || childSessionTaskSummaryEntries.length === 0) {
return;
}
if (typeof window === 'undefined') {
setTaskChildPollingStopped(true);
return;
}
const timer = window.setTimeout(() => {
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
activeLatched,
isFinalized,
isTaskTool,
taskChildPollingStopped,
taskChildSeenActive,
taskSessionId,
]);
React.useEffect(() => {
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
setLocalFinalizedAt(undefined);
@@ -1730,7 +1837,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
return;
}
const shouldPoll = isActive || childSessionHasInFlightTools || childSessionTaskSummaryEntries.length === 0;
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
const shouldPoll =
!taskChildPollingStopped
&& (isActive || childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll;
if (!shouldFetchSnapshot) {
return;
@@ -1739,33 +1849,83 @@ const ToolPart: React.FC<ToolPartProps> = ({
let cancelled = false;
let pollTimer: number | undefined;
const fetchSessionMessages = async () => {
const isVisible = () => {
if (typeof document === 'undefined') {
return true;
}
return document.visibilityState === 'visible';
};
const resolveFetchLimit = (isInitialFetch: boolean) => {
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return TASK_TOOL_INITIAL_FETCH_LIMIT;
}
if (isActive || childSessionHasInFlightTools || childSessionActive) {
return TASK_TOOL_ACTIVE_FETCH_LIMIT;
}
return TASK_TOOL_IDLE_FETCH_LIMIT;
};
const resolvePollDelay = () => {
if (!isVisible()) {
return TASK_TOOL_POLL_HIDDEN_MS;
}
if (taskPollNoChangeCountRef.current >= TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS) {
return TASK_TOOL_POLL_IDLE_MS;
}
return TASK_TOOL_POLL_FAST_MS;
};
const scheduleNextPoll = () => {
if (!shouldPoll || typeof window === 'undefined' || cancelled) {
return;
}
pollTimer = window.setTimeout(() => {
pollTimer = undefined;
void fetchSessionMessages(false);
}, resolvePollDelay());
};
const fetchSessionMessages = async (isInitialFetch: boolean) => {
try {
const messages = await opencodeClient.getSessionMessages(taskSessionId, 500);
const messages = await opencodeClient.getSessionMessages(taskSessionId, resolveFetchLimit(isInitialFetch));
if (cancelled || !Array.isArray(messages) || messages.length === 0) {
return;
}
const nextSignature = buildTaskSessionMessagesSignature(messages as SessionMessageWithParts[]);
if (nextSignature === taskPollLastSignatureRef.current) {
taskPollNoChangeCountRef.current += 1;
return;
}
taskPollLastSignatureRef.current = nextSignature;
taskPollNoChangeCountRef.current = 0;
useSessionStore.getState().syncMessages(taskSessionId, messages);
} catch {
// Ignore transient subagent fetch errors.
} finally {
scheduleNextPoll();
}
};
void fetchSessionMessages();
if (shouldPoll && typeof window !== 'undefined') {
pollTimer = window.setInterval(() => {
void fetchSessionMessages();
}, 1200);
}
void fetchSessionMessages(true);
return () => {
cancelled = true;
if (typeof pollTimer === 'number') {
window.clearInterval(pollTimer);
window.clearTimeout(pollTimer);
}
};
}, [childSessionHasInFlightTools, childSessionTaskSummaryEntries.length, isActive, isTaskTool, taskSessionId]);
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
isActive,
isTaskTool,
taskChildPollingStopped,
taskSessionId,
]);
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
@@ -25,7 +25,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
return;
}
target.style.opacity = '';
target.style.filter = '';
// target.style.filter = '';
target.style.transform = '';
target.style.maskImage = '';
target.style.webkitMaskImage = '';
@@ -61,7 +61,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
CSS.supports('-webkit-mask-image', 'linear-gradient(to right, black, transparent)'));
el.style.opacity = '0';
el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
// el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
el.style.transform = wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)';
if (maskSupported) {
@@ -84,16 +84,15 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
const keyframes: Keyframe[] = maskSupported
? [
{ opacity: 0, filter: 'blur(3px)', transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
{ opacity: 1, filter: 'blur(0px)', transform: 'translateX(0)', maskPosition: '0% 0%' },
{ opacity: 0, transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
{ opacity: 1, transform: 'translateX(0)', maskPosition: '0% 0%' },
]
: [
{
opacity: 0,
filter: wipe ? 'blur(3px)' : 'blur(2px)',
transform: wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)',
},
{ opacity: 1, filter: 'blur(0px)', transform: wipe ? 'translateX(0)' : 'translateY(0)' },
{ opacity: 1, transform: wipe ? 'translateX(0)' : 'translateY(0)' },
];
animation = node.animate(keyframes, {
@@ -0,0 +1,70 @@
import React from 'react';
type Subscriber = (now: number) => void;
type TickerChannel = {
subscribers: Set<Subscriber>;
timerId: number | null;
};
const tickerChannels = new Map<number, TickerChannel>();
const getTickerChannel = (intervalMs: number): TickerChannel => {
const existing = tickerChannels.get(intervalMs);
if (existing) {
return existing;
}
const created: TickerChannel = {
subscribers: new Set<Subscriber>(),
timerId: null,
};
tickerChannels.set(intervalMs, created);
return created;
};
const subscribeToTicker = (intervalMs: number, subscriber: Subscriber): (() => void) => {
const channel = getTickerChannel(intervalMs);
channel.subscribers.add(subscriber);
subscriber(Date.now());
if (channel.timerId === null && typeof window !== 'undefined') {
channel.timerId = window.setInterval(() => {
const now = Date.now();
channel.subscribers.forEach((listener) => {
listener(now);
});
}, intervalMs);
}
return () => {
const tracked = tickerChannels.get(intervalMs);
if (!tracked) {
return;
}
tracked.subscribers.delete(subscriber);
if (tracked.subscribers.size > 0) {
return;
}
if (tracked.timerId !== null && typeof window !== 'undefined') {
window.clearInterval(tracked.timerId);
}
tickerChannels.delete(intervalMs);
};
};
export const useDurationTickerNow = (active: boolean, intervalMs: number = 250): number => {
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (!active) {
return;
}
return subscribeToTicker(intervalMs, setNow);
}, [active, intervalMs]);
return now;
};
@@ -681,14 +681,14 @@ export function DesktopHostSwitcherDialog({
const content = (
<>
{embedded ? (
<div className="flex-shrink-0 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-2.5">
<div className="flex-shrink-0 border-b border-[var(--interactive-border)] px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-2">
<span className="typography-ui-header font-semibold text-foreground">Current</span>
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{redactSensitiveUrl(current.label)}</span>
<span className="text-muted-foreground"></span>
<span className="typography-ui-header font-semibold text-foreground">Default</span>
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{redactSensitiveUrl(currentDefaultLabel)}</span>
<div className="min-w-0 flex items-baseline gap-1.5 typography-ui-label">
<span className="font-medium text-foreground">Current</span>
<span className="max-w-[9rem] truncate text-muted-foreground">{redactSensitiveUrl(current.label)}</span>
<span className="text-muted-foreground/50"></span>
<span className="font-medium text-foreground">Default</span>
<span className="max-w-[9rem] truncate text-muted-foreground">{redactSensitiveUrl(currentDefaultLabel)}</span>
</div>
<button
type="button"
@@ -741,14 +741,12 @@ export function DesktopHostSwitcherDialog({
)}
{tauriAvailable && (
<div className="flex-shrink-0 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">Need SSH instances? Manage them in Settings.</span>
<Button type="button" variant="outline" size="sm" onClick={openRemoteInstancesSettings}>
<RiSettings3Line className="h-4 w-4" />
Remote SSH
</Button>
</div>
<div className="flex-shrink-0 flex items-center justify-between gap-2 px-2.5 py-1.5">
<span className="typography-micro text-muted-foreground">Need SSH instances?<br />Manage them in Settings.</span>
<Button type="button" variant="ghost" size="sm" onClick={openRemoteInstancesSettings}>
<RiSettings3Line className="h-4 w-4" />
Remote SSH
</Button>
</div>
)}
@@ -48,7 +48,7 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
if (!parentHeight || parentHeight <= 0) {
return;
}
const next = Math.round(parentHeight);
const next = Math.max(0, Math.round(parentHeight));
setFullscreenHeight((prev) => (prev === next ? prev : next));
};
@@ -103,7 +103,7 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
}
const appliedHeight = isOpen
? (isFullscreen ? Math.max(standardHeight, fullscreenHeight ?? standardHeight) : standardHeight)
? (isFullscreen ? Math.max(0, fullscreenHeight ?? standardHeight) : standardHeight)
: 0;
const handlePointerDown = (event: React.PointerEvent) => {
@@ -146,8 +146,8 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
{isOpen && !isFullscreen && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-[4px] w-full cursor-row-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
'absolute left-0 top-0 z-20 h-[3px] w-full cursor-row-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handlePointerDown}
role="separator"
@@ -427,7 +427,7 @@ export const ContextPanel: React.FC = () => {
const isFileTabActive = activeTab?.mode === 'file';
const header = (
<header className="flex h-8 items-stretch border-b border-border/40">
<header className="flex h-10 items-stretch border-b border-transparent">
<SortableTabsStrip
items={tabItems}
activeId={activeTab?.id ?? null}
@@ -450,8 +450,11 @@ export const ContextPanel: React.FC = () => {
reorderContextPanelTabs(directoryKey, activeTabID, overTabID);
}}
layoutMode="scrollable"
variant="active-pill"
activePillLowercase={false}
activePillInsetClassName="gap-0.5 pt-0.5 pb-1.5"
/>
<div className="flex items-center gap-1 px-1.5">
<div className="flex items-end gap-1 px-1.5 pb-1.5">
<Button
type="button"
variant="ghost"
@@ -515,8 +518,8 @@ export const ContextPanel: React.FC = () => {
{!isExpanded && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize transition-colors hover:bg-primary/50',
isResizing && 'bg-primary'
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
File diff suppressed because it is too large Load Diff
+158 -46
View File
@@ -2,9 +2,8 @@ import React, { useRef, useEffect } from 'react';
import { motion, useMotionValue, animate } from 'motion/react';
import { Header } from './Header';
import { BottomTerminalDock } from './BottomTerminalDock';
import { Sidebar } from './Sidebar';
import { NavRail } from './NavRail';
import { RightSidebar } from './RightSidebar';
import { Sidebar, SIDEBAR_CONTENT_WIDTH } from './Sidebar';
import { RightSidebar, RIGHT_SIDEBAR_CONTENT_WIDTH } from './RightSidebar';
import { RightSidebarTabs } from './RightSidebarTabs';
import { ContextPanel } from './ContextPanel';
import { ErrorBoundary } from '../ui/ErrorBoundary';
@@ -22,11 +21,16 @@ import { useUpdateStore } from '@/stores/useUpdateStore';
import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { isDesktopShell } from '@/lib/desktop';
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
// Mobile drawer width as screen percentage
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
const DESKTOP_SIDEBAR_MIN_WIDTH = 250;
const DESKTOP_SIDEBAR_MAX_WIDTH = 500;
const DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH = 400;
const DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH = 860;
const normalizeDirectoryKey = (value: string): string => {
if (!value) return '';
@@ -69,6 +73,10 @@ export const MainLayout: React.FC = () => {
} = useUIStore();
const { isMobile } = useDeviceInfo();
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
const [desktopRightSidebarActionsHost, setDesktopRightSidebarActionsHost] = React.useState<HTMLDivElement | null>(null);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
const isContextPanelOpen = useUIStore((state) => {
@@ -590,6 +598,14 @@ export const MainLayout: React.FC = () => {
}, [activeMainTab]);
const isChatActive = activeMainTab === 'chat';
const visibleSidebarWidth = React.useMemo(() => {
const rawWidth = sidebarWidth || SIDEBAR_CONTENT_WIDTH;
return Math.min(DESKTOP_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_SIDEBAR_MIN_WIDTH, rawWidth));
}, [sidebarWidth]);
const visibleRightSidebarWidth = React.useMemo(() => {
const rawWidth = rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH;
return Math.min(DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH, rawWidth));
}, [rightSidebarWidth]);
return (
<DiffWorkerProvider>
@@ -597,7 +613,7 @@ export const MainLayout: React.FC = () => {
className={cn(
'main-content-safe-area h-[100dvh]',
isMobile ? 'flex flex-col' : 'flex',
'bg-background'
isDesktopShellRuntime ? 'bg-transparent' : 'bg-background'
)}
>
<CommandPalette />
@@ -654,7 +670,7 @@ export const MainLayout: React.FC = () => {
opacity: mobileLeftDrawerOpen || isRightSidebarOpen ? 1 : 0,
pointerEvents: mobileLeftDrawerOpen || isRightSidebarOpen ? 'auto' : 'none',
}}
className="fixed inset-0 z-40 bg-black/50 cursor-default"
className="fixed left-0 right-0 bottom-0 top-[var(--oc-header-height,56px)] z-40 bg-black/50 cursor-default"
onClick={() => {
setMobileLeftDrawerOpen(false);
setRightSidebarOpen(false);
@@ -696,15 +712,15 @@ export const MainLayout: React.FC = () => {
}
}}
className={cn(
'fixed left-0 top-0 z-50 h-full bg-transparent',
'fixed left-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-transparent',
'cursor-grab active:cursor-grabbing'
)}
aria-hidden={!mobileLeftDrawerOpen}
>
<div className="h-full overflow-hidden flex bg-sidebar shadow-none drawer-safe-area">
<div onPointerDownCapture={(e) => e.stopPropagation()}>
<NavRail className="shrink-0" mobile />
</div>
<div
className="h-full overflow-hidden flex bg-[var(--surface-background)] shadow-none drawer-safe-area"
style={{ backgroundImage: 'linear-gradient(var(--surface-muted), var(--surface-muted))' }}
>
<div className="flex-1 min-w-0 overflow-hidden flex flex-col">
<ErrorBoundary>
<SessionSidebar mobileVariant />
@@ -747,7 +763,7 @@ export const MainLayout: React.FC = () => {
}
}}
className={cn(
'fixed right-0 top-0 z-50 h-full bg-transparent',
'fixed right-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-transparent',
'cursor-grab active:cursor-grabbing'
)}
aria-hidden={!isRightSidebarOpen}
@@ -765,7 +781,6 @@ export const MainLayout: React.FC = () => {
'flex flex-1 overflow-hidden relative',
(isSettingsDialogOpen || isMultiRunLauncherOpen) && 'hidden'
)}
style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
>
<main className="w-full h-full overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
@@ -781,7 +796,10 @@ export const MainLayout: React.FC = () => {
{/* Mobile multi-run launcher: full screen */}
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background header-safe-area">
<div
className="absolute inset-0 z-10 bg-background"
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
>
<ErrorBoundary>
<MultiRunLauncher
initialPrompt={multiRunLauncherPrefillPrompt}
@@ -794,49 +812,143 @@ export const MainLayout: React.FC = () => {
{/* Mobile settings: full screen */}
{isSettingsDialogOpen && (
<div className="absolute inset-0 z-10 bg-background header-safe-area">
<div
className="absolute inset-0 z-10 bg-background"
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
>
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
</div>
)}
</DrawerProvider>
) : (
<>
{/* Desktop: Header always on top, then Sidebar + Content below */}
<div className="flex flex-1 flex-col overflow-hidden relative">
{/* Normal view: Header above Sidebar + content (like SettingsView) */}
<div className={cn('absolute inset-0 flex flex-col', isMultiRunLauncherOpen && 'invisible')}>
<Header />
<div className="flex flex-1 overflow-hidden">
<NavRail />
<div className="flex flex-1 min-w-0 overflow-hidden border-t border-l border-border/50 rounded-tl-xl">
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
<SessionSidebar hideProjectSelector />
</Sidebar>
<div className="flex flex-1 min-w-0 flex-col overflow-hidden">
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
<main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
{/* Desktop: Sidebar is a left column; header belongs to content column */}
<div className="flex flex-1 overflow-hidden relative">
<div className={cn(
'absolute inset-0 flex overflow-hidden',
isDesktopShellRuntime
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar',
isMultiRunLauncherOpen && 'invisible'
)}>
{isSidebarOpen ? (
<>
<div
aria-hidden
className={cn(
'pointer-events-none absolute top-0 z-0',
isDesktopShellRuntime
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar'
)}
style={{
left: `${visibleSidebarWidth}px`,
width: 'var(--radius-md)',
height: 'var(--radius-md)',
WebkitMaskImage: 'radial-gradient(circle at 100% 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
maskImage: 'radial-gradient(circle at 100% 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
}}
/>
<div
aria-hidden
className={cn(
'pointer-events-none absolute bottom-0 z-0',
isDesktopShellRuntime
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar'
)}
style={{
left: `${visibleSidebarWidth}px`,
width: 'var(--radius-md)',
height: 'var(--radius-md)',
WebkitMaskImage: 'radial-gradient(circle at 100% 0%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
maskImage: 'radial-gradient(circle at 100% 0%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
}}
/>
</>
) : null}
{isRightSidebarOpen ? (
<>
<div
aria-hidden
className={cn(
'pointer-events-none absolute top-0 z-0',
isDesktopShellRuntime
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar'
)}
style={{
right: `${visibleRightSidebarWidth}px`,
width: 'var(--radius-md)',
height: 'var(--radius-md)',
WebkitMaskImage: 'radial-gradient(circle at 0 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
maskImage: 'radial-gradient(circle at 0 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
}}
/>
<div
aria-hidden
className={cn(
'pointer-events-none absolute bottom-0 z-0',
isDesktopShellRuntime
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar'
)}
style={{
right: `${visibleRightSidebarWidth}px`,
width: 'var(--radius-md)',
height: 'var(--radius-md)',
WebkitMaskImage: 'radial-gradient(circle at 0 0, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
maskImage: 'radial-gradient(circle at 0 0, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
}}
/>
</>
) : null}
<Sidebar
isOpen={isSidebarOpen}
isMobile={isMobile}
className="border-0"
>
<SessionSidebar />
</Sidebar>
<div className={cn(
'relative flex flex-1 min-w-0 flex-col overflow-hidden',
isDesktopShellRuntime
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar',
isSidebarOpen && 'border-l border-border/50 rounded-tl-md rounded-bl-md',
isRightSidebarOpen && 'border-r border-border/50 rounded-tr-md rounded-br-md'
)}>
<Header desktopRightSidebarActionsHost={desktopRightSidebarActionsHost} />
<div className={cn(
'flex flex-1 min-h-0 overflow-hidden',
isSidebarOpen || isChatActive ? '' : 'border-l border-border/50',
isRightSidebarOpen ? '' : 'border-r border-border/50'
)}>
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
<main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
</div>
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
</main>
<ContextPanel />
</div>
<RightSidebar isOpen={isRightSidebarOpen}>
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
</RightSidebar>
)}
</main>
<ContextPanel />
</div>
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
<ErrorBoundary><TerminalView /></ErrorBoundary>
</BottomTerminalDock>
</div>
</div>
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
<ErrorBoundary><TerminalView /></ErrorBoundary>
</BottomTerminalDock>
</div>
<RightSidebar
isOpen={isRightSidebarOpen}
className="border-0"
onTopActionsHostChange={setDesktopRightSidebarActionsHost}
>
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
</RightSidebar>
</div>
{/* Multi-Run Launcher: replaces tabs content only */}
@@ -1,919 +0,0 @@
import React from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
RiFolderAddLine,
RiSettings3Line,
RiQuestionLine,
RiDownloadLine,
RiInformationLine,
RiPencilLine,
RiCloseLine,
RiMenuFoldLine,
RiMenuUnfoldLine,
} from '@remixicon/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
import { useLongPress } from '@/hooks/useLongPress';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { sessionEvents } from '@/lib/sessionEvents';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ProjectEntry } from '@/lib/api/types';
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
const NAV_RAIL_WIDTH = 56;
const NAV_RAIL_EXPANDED_WIDTH = 200;
const NAV_RAIL_TEXT_FADE_MS = 180;
const PROJECT_TEXT_FADE_IN_DELAY_MS = 24;
const ACTION_TEXT_FADE_IN_DELAY_MS = 60;
type NavRailActionButtonProps = {
onClick: () => void;
disabled?: boolean;
ariaLabel: string;
icon: React.ReactNode;
tooltipLabel: string;
shortcutHint?: string;
showExpandedShortcutHint?: boolean;
buttonClassName: string;
showExpandedContent: boolean;
actionTextVisible: boolean;
};
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
onClick,
disabled = false,
ariaLabel,
icon,
tooltipLabel,
shortcutHint,
showExpandedShortcutHint = true,
buttonClassName,
showExpandedContent,
actionTextVisible,
}) => {
const pointerTriggeredRef = React.useRef(false);
const pointerPressRef = React.useRef<{ active: boolean; pointerId: number | null }>({
active: false,
pointerId: null,
});
const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
if (disabled || event.button !== 0) {
pointerPressRef.current = { active: false, pointerId: null };
return;
}
pointerPressRef.current = { active: true, pointerId: event.pointerId };
}, [disabled]);
const clearPointerPress = React.useCallback(() => {
pointerPressRef.current = { active: false, pointerId: null };
}, []);
const handlePointerUp = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
if (disabled) return;
if (event.button !== 0) return;
const pointerPress = pointerPressRef.current;
if (!pointerPress.active || pointerPress.pointerId !== event.pointerId) {
return;
}
clearPointerPress();
pointerTriggeredRef.current = true;
onClick();
}, [clearPointerPress, disabled, onClick]);
const handleClick = React.useCallback(() => {
if (disabled) return;
if (pointerTriggeredRef.current) {
pointerTriggeredRef.current = false;
return;
}
onClick();
}, [disabled, onClick]);
const btn = (
<button
type="button"
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={clearPointerPress}
onPointerLeave={clearPointerPress}
onClick={handleClick}
className={buttonClassName}
aria-label={ariaLabel}
disabled={disabled}
>
{showExpandedContent && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg bg-transparent transition-colors group-hover:bg-[var(--interactive-hover)]/50"
/>
)}
<span className="relative z-10 flex size-8 basis-8 shrink-0 grow-0 items-center justify-center">
{icon}
</span>
<span
aria-hidden={!actionTextVisible}
className={cn(
'relative z-10 min-w-0 flex items-center justify-between gap-1 overflow-hidden transition-opacity duration-[180ms] ease-in-out',
showExpandedContent ? 'flex-1' : 'w-0 flex-none',
actionTextVisible ? 'opacity-100' : 'opacity-0',
)}
>
<span className="truncate text-left text-[13px]">{tooltipLabel}</span>
{shortcutHint && showExpandedShortcutHint && (
<span className="shrink-0 text-[10px] text-[var(--surface-mutedForeground)] opacity-70">
{shortcutHint}
</span>
)}
</span>
</button>
);
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
{!showExpandedContent && (
<TooltipContent side="right" sideOffset={8}>
<p>{shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}</p>
</TooltipContent>
)}
</Tooltip>
);
};
/** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */
const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({
colorVar,
children,
}) => (
<span
className="relative flex h-full w-full items-center justify-center rounded-lg overflow-hidden"
style={{ backgroundColor: 'var(--surface-muted)' }}
>
{colorVar && (
<span
className="absolute inset-0 opacity-15"
style={{ backgroundColor: colorVar }}
/>
)}
<span className="relative z-10 flex items-center justify-center">
{children}
</span>
</span>
);
/** First-letter avatar fallback */
const LetterAvatar: React.FC<{ label: string; color?: string | null }> = ({
label,
color,
}) => {
const letter = label.charAt(0).toUpperCase() || '?';
const colorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
return (
<span
className="flex h-4 w-4 items-center justify-center text-[15px] font-medium leading-none select-none"
style={{ color: colorVar ?? 'var(--surface-foreground)', fontFamily: 'var(--font-mono, monospace)' }}
>
{letter}
</span>
);
};
const ProjectStatusDots: React.FC<{
color: string;
variant?: 'streaming' | 'attention' | 'none';
size?: 'sm' | 'md';
}> = ({ color, variant = 'none', size = 'md' }) => (
<span className="inline-flex items-center justify-center gap-px" aria-hidden="true">
{Array.from({ length: 3 }).map((_, index) => (
<span key={index} className="inline-flex h-[3px] w-[3px] items-center justify-center">
<span
className={cn(
size === 'sm' ? 'h-[2.5px] w-[2.5px]' : 'h-[3px] w-[3px]',
'rounded-full',
variant === 'streaming' && 'animate-grid-pulse',
variant === 'attention' && 'animate-attention-diamond-pulse'
)}
style={{
backgroundColor: color,
animationDelay: variant === 'streaming'
? `${index * 150}ms`
: variant === 'attention'
? (index === 1 ? '0ms' : '130ms')
: undefined,
}}
/>
</span>
))}
</span>
);
/** Single project tile in the nav rail — right-click for context menu (no visible 3-dot) */
const ProjectTile: React.FC<{
project: ProjectEntry;
isActive: boolean;
hasStreaming: boolean;
hasUnread: boolean;
label: string;
expanded: boolean;
projectTextVisible: boolean;
onClick: () => void;
onEdit: () => void;
onClose: () => void;
}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
const { currentTheme } = useThemeSystem();
const [menuOpen, setMenuOpen] = React.useState(false);
const [iconImageFailed, setIconImageFailed] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectIconImageUrl = !iconImageFailed
? getProjectIconImageUrl(project, {
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
})
: null;
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
const showStreamingDots = hasStreaming;
const showAttentionDots = !hasStreaming && hasUnread;
React.useEffect(() => {
setIconImageFailed(false);
}, [project.id, project.iconImage?.updatedAt]);
const longPressHandlers = useLongPress({
onLongPress: () => setMenuOpen(true),
onTap: onClick,
});
const iconElement = (
<TileBackground colorVar={projectColorVar}>
<span className="relative h-full w-full leading-none">
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
{projectIconImageUrl ? (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img
src={projectIconImageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setIconImageFailed(true)}
/>
</span>
) : ProjectIcon ? (
<ProjectIcon
className="h-4 w-4 shrink-0"
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }}
/>
) : (
<LetterAvatar label={label} color={project.color} />
)}
</span>
{showStreamingDots && (
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
<ProjectStatusDots color="var(--primary)" variant="streaming" />
</span>
)}
{showAttentionDots && (
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
<ProjectStatusDots color="var(--status-info)" variant="attention" />
</span>
)}
</span>
</TileBackground>
);
const tileButton = (
<button
type="button"
{...longPressHandlers}
className={cn(
'group relative flex cursor-pointer items-center rounded-lg overflow-hidden',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
expanded ? 'h-9 w-full gap-2.5 pr-1.5 pl-[7px]' : 'h-9 w-9 justify-center',
!expanded && (
isActive
? 'bg-transparent border border-[var(--surface-foreground)]'
: 'bg-transparent border border-transparent hover:bg-[var(--interactive-hover)]/50 hover:border-[var(--interactive-border)]'
),
!expanded && menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
)}
>
{expanded && (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg border transition-colors',
isActive
? 'bg-[var(--interactive-selection)] border-[var(--interactive-border)]'
: 'bg-transparent border-transparent group-hover:bg-[var(--interactive-hover)]/50 group-hover:border-[var(--interactive-border)]',
menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
)}
/>
)}
<span className="flex size-[34px] basis-[34px] shrink-0 grow-0 items-center justify-center">
{iconElement}
</span>
<span
aria-hidden={!projectTextVisible}
className={cn(
'relative z-10 min-w-0 truncate text-left text-[13px] leading-tight transition-opacity duration-[180ms] ease-in-out',
expanded ? 'flex-1' : 'w-0 flex-none',
projectTextVisible ? 'opacity-100' : 'opacity-0',
isActive && expanded ? 'font-medium text-[var(--interactive-selection-foreground)]' : 'text-[var(--surface-foreground)]',
)}
>
{label}
</span>
</button>
);
return (
<>
{expanded ? (
<div
className="relative w-full"
onContextMenu={(e) => {
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
e.preventDefault();
setMenuOpen(true);
}
}}
>
{tileButton}
</div>
) : (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<div
className="relative"
onContextMenu={(e) => {
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
e.preventDefault();
setMenuOpen(true);
}
}}
>
{tileButton}
</div>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
)}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<span className="sr-only">Project options</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right" sideOffset={4} className="min-w-[160px]">
<DropdownMenuItem onClick={onEdit} className="gap-2">
<RiPencilLine className="h-4 w-4" />
Edit project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive gap-2"
>
<RiCloseLine className="h-4 w-4" />
Close project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
/** Constrain drag to Y axis only */
const restrictToYAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
/** Sortable wrapper for ProjectTile */
const SortableProjectTile: React.FC<{
id: string;
children: React.ReactNode;
}> = ({ id, children }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className={cn(isDragging && 'opacity-30 z-50')}
{...attributes}
{...listeners}
>
{children}
</div>
);
};
interface NavRailProps {
className?: string;
mobile?: boolean;
}
export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((s) => s.setActiveProjectIdOnly);
const addProject = useProjectsStore((s) => s.addProject);
const removeProject = useProjectsStore((s) => s.removeProject);
const reorderProjects = useProjectsStore((s) => s.reorderProjects);
const updateProjectMeta = useProjectsStore((s) => s.updateProjectMeta);
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const isOverlayBlockingNavRailActions = useUIStore((s) => (
s.isSettingsDialogOpen
|| s.isHelpDialogOpen
|| s.isCommandPaletteOpen
|| s.isSessionSwitcherOpen
|| s.isAboutDialogOpen
|| s.isOpenCodeStatusDialogOpen
|| s.isSessionCreateDialogOpen
|| s.isModelSelectorOpen
|| s.isTimelineDialogOpen
|| s.isMultiRunLauncherOpen
|| s.isImagePreviewOpen
));
const isNavRailExpanded = useUIStore((s) => s.isNavRailExpanded);
const toggleNavRail = useUIStore((s) => s.toggleNavRail);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
const expanded = !mobile && isNavRailExpanded;
const [showExpandedContent, setShowExpandedContent] = React.useState(expanded);
const [projectTextVisible, setProjectTextVisible] = React.useState(expanded);
const [actionTextVisible, setActionTextVisible] = React.useState(expanded);
React.useEffect(() => {
if (expanded) {
setShowExpandedContent(true);
setProjectTextVisible(false);
setActionTextVisible(false);
const projectTimer = window.setTimeout(() => {
setProjectTextVisible(true);
}, PROJECT_TEXT_FADE_IN_DELAY_MS);
const actionTimer = window.setTimeout(() => {
setActionTextVisible(true);
}, ACTION_TEXT_FADE_IN_DELAY_MS);
return () => {
window.clearTimeout(projectTimer);
window.clearTimeout(actionTimer);
};
}
setProjectTextVisible(false);
setActionTextVisible(false);
const timer = window.setTimeout(() => {
setShowExpandedContent(false);
}, NAV_RAIL_TEXT_FADE_MS);
return () => {
window.clearTimeout(timer);
};
}, [expanded]);
const shortcutLabel = React.useCallback((actionId: string) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
}, [shortcutOverrides]);
const sessionStatus = useSessionStore((s) => s.sessionStatus);
const sessionAttentionStates = useSessionStore((s) => s.sessionAttentionStates);
const sessionsByDirectory = useSessionStore((s) => s.sessionsByDirectory);
const getSessionsByDirectory = useSessionStore((s) => s.getSessionsByDirectory);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const availableWorktreesByProject = useSessionStore((s) => s.availableWorktreesByProject);
const updateStore = useUpdateStore();
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
const navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
const [editingProject, setEditingProject] = React.useState<{
id: string;
name: string;
path: string;
icon?: string | null;
color?: string | null;
iconBackground?: string | null;
} | null>(null);
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const formatLabel = React.useCallback(
(project: ProjectEntry): string => {
return (
project.label?.trim() ||
formatDirectoryName(project.path, homeDirectory) ||
project.path
);
},
[homeDirectory],
);
const projectIndicators = React.useMemo(() => {
const result = new Map<string, { hasStreaming: boolean; hasUnread: boolean }>();
for (const project of projects) {
const projectRoot = normalize(project.path);
if (!projectRoot) {
result.set(project.id, { hasStreaming: false, hasUnread: false });
continue;
}
const dirs: string[] = [projectRoot];
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
for (const meta of worktrees) {
const p =
meta && typeof meta === 'object' && 'path' in meta
? (meta as { path?: unknown }).path
: null;
if (typeof p === 'string' && p.trim()) {
const normalized = normalize(p);
if (normalized && normalized !== projectRoot) {
dirs.push(normalized);
}
}
}
const seen = new Set<string>();
let hasStreaming = false;
let hasUnread = false;
for (const dir of dirs) {
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
for (const session of list) {
if (!session?.id || seen.has(session.id)) continue;
seen.add(session.id);
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
hasStreaming = true;
}
const isCurrentVisible =
session.id === currentSessionId && project.id === activeProjectId;
if (
!isCurrentVisible &&
sessionAttentionStates.get(session.id)?.needsAttention === true
) {
hasUnread = true;
}
if (hasStreaming && hasUnread) break;
}
if (hasStreaming && hasUnread) break;
}
result.set(project.id, { hasStreaming, hasUnread });
}
return result;
}, [
activeProjectId,
availableWorktreesByProject,
currentSessionId,
getSessionsByDirectory,
projects,
sessionAttentionStates,
sessionStatus,
sessionsByDirectory,
]);
const handleAddProject = React.useCallback(() => {
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
sessionEvents.requestDirectoryDialog();
return;
}
requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
});
}
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', { description: result.error });
}
})
.catch((error) => {
console.error('Failed to select directory:', error);
toast.error('Failed to select directory');
});
}, [addProject, tauriIpcAvailable]);
const handleEditProject = React.useCallback(
(projectId: string) => {
const project = projects.find((p) => p.id === projectId);
if (!project) return;
setEditingProject({
id: project.id,
name: formatLabel(project),
path: project.path,
icon: project.icon,
color: project.color,
iconBackground: project.iconBackground,
});
},
[projects, formatLabel],
);
const handleSaveProjectEdit = React.useCallback(
(data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
if (!editingProject) return;
updateProjectMeta(editingProject.id, data);
setEditingProject(null);
},
[editingProject, updateProjectMeta],
);
const handleCloseProject = React.useCallback(
(projectId: string) => {
removeProject(projectId);
},
[removeProject],
);
// Cmd/Ctrl+number to switch projects
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= projects.length) {
e.preventDefault();
const target = projects[num - 1];
if (target && target.id !== activeProjectId) {
setActiveProjectIdOnly(target.id);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [projects, activeProjectId, setActiveProjectIdOnly]);
// Drag-to-reorder
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const projectIds = React.useMemo(() => projects.map((p) => p.id), [projects]);
const handleDragEnd = React.useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const fromIndex = projects.findIndex((p) => p.id === active.id);
const toIndex = projects.findIndex((p) => p.id === over.id);
if (fromIndex !== -1 && toIndex !== -1) {
reorderProjects(fromIndex, toIndex);
}
},
[projects, reorderProjects],
);
const navRailActionButtonClass = cn(
'group relative flex h-8 cursor-pointer items-center rounded-lg disabled:cursor-not-allowed',
showExpandedContent ? 'w-full justify-start gap-2.5 pr-2 pl-2' : 'w-8 justify-center',
showExpandedContent
? 'text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)]'
: 'text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)]/50 hover:text-[var(--surface-foreground)]',
'transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
);
const navRailActionIconClass = 'h-4.5 w-4.5 shrink-0';
return (
<>
<nav
className={cn(
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
showExpandedContent ? 'items-stretch' : 'items-center',
navRailInteractionBlocked && 'pointer-events-none',
className,
)}
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
aria-label="Project navigation"
>
{/* Projects list */}
<div className="flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToYAxis]}
>
<SortableContext items={projectIds} strategy={verticalListSortingStrategy}>
<div className={cn('flex flex-col gap-3 pt-1 pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
{projects.map((project) => {
const isActive = project.id === activeProjectId;
const indicators = projectIndicators.get(project.id);
return (
<SortableProjectTile key={project.id} id={project.id}>
<ProjectTile
project={project}
isActive={isActive}
hasStreaming={indicators?.hasStreaming ?? false}
hasUnread={indicators?.hasUnread ?? false}
label={formatLabel(project)}
expanded={showExpandedContent}
projectTextVisible={projectTextVisible}
onClick={() => {
if (project.id !== activeProjectId) {
setActiveProjectIdOnly(project.id);
}
}}
onEdit={() => handleEditProject(project.id)}
onClose={() => handleCloseProject(project.id)}
/>
</SortableProjectTile>
);
})}
</div>
</SortableContext>
</DndContext>
{/* Add project button */}
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
<NavRailActionButton
onClick={handleAddProject}
disabled={navRailInteractionBlocked}
ariaLabel="Add project"
icon={<RiFolderAddLine className={navRailActionIconClass} />}
tooltipLabel="Add project"
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
</div>
</div>
{/* Bottom actions */}
<div className={cn(
'shrink-0 w-full pt-3 pb-4 flex flex-col gap-1',
showExpandedContent ? 'items-stretch px-1' : 'items-center',
)}>
{(updateAvailable || updateDownloaded) && (
<NavRailActionButton
onClick={() => setUpdateDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="Update available"
icon={<RiDownloadLine className={navRailActionIconClass} />}
tooltipLabel="Update available"
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
<NavRailActionButton
onClick={() => setAboutDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="About"
icon={<RiInformationLine className={navRailActionIconClass} />}
tooltipLabel="About OpenChamber"
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
{!mobile && (
<NavRailActionButton
onClick={toggleHelpDialog}
disabled={navRailInteractionBlocked}
ariaLabel="Keyboard shortcuts"
icon={<RiQuestionLine className={navRailActionIconClass} />}
tooltipLabel="Shortcuts"
shortcutHint={shortcutLabel('open_help')}
showExpandedShortcutHint={false}
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
<NavRailActionButton
onClick={() => setSettingsDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="Settings"
icon={<RiSettings3Line className={navRailActionIconClass} />}
tooltipLabel="Settings"
shortcutHint={shortcutLabel('open_settings')}
showExpandedShortcutHint={false}
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
{/* Toggle expand/collapse (desktop only) */}
{!mobile && (
<NavRailActionButton
onClick={toggleNavRail}
disabled={navRailInteractionBlocked}
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
icon={expanded
? <RiMenuFoldLine className={navRailActionIconClass} />
: <RiMenuUnfoldLine className={navRailActionIconClass} />
}
tooltipLabel={expanded ? 'Collapse' : 'Expand'}
shortcutHint={shortcutLabel('toggle_nav_rail')}
showExpandedShortcutHint={false}
buttonClassName={navRailActionButtonClass}
showExpandedContent={showExpandedContent}
actionTextVisible={actionTextVisible}
/>
)}
</div>
</nav>
{/* Dialogs */}
{editingProject && (
<ProjectEditDialog
open={!!editingProject}
onOpenChange={(open) => {
if (!open) setEditingProject(null);
}}
projectId={editingProject.id}
projectName={editingProject.name}
projectPath={editingProject.path}
initialIcon={editingProject.icon}
initialColor={editingProject.color}
initialIconBackground={editingProject.iconBackground}
onSave={handleSaveProjectEdit}
/>
)}
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
info={updateStore.info}
downloading={updateStore.downloading}
downloaded={updateStore.downloaded}
progress={updateStore.progress}
error={updateStore.error}
onDownload={updateStore.downloadUpdate}
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
</>
);
};
export { NAV_RAIL_WIDTH, NAV_RAIL_EXPANDED_WIDTH };
@@ -153,6 +153,26 @@ const extractBestUrl = (value: string): string | null => {
return normalized[0] ?? null;
};
const formatActionButtonLabel = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return 'Action';
}
const words = trimmed.split(/\s+/).filter(Boolean);
if (words.length >= 2) {
const first = words[0];
const second = words[1].slice(0, 3);
const shortTwoWord = `${first} ${second}`.trim();
if (words.length > 2 || shortTwoWord.length < trimmed.length) {
return `${shortTwoWord}...`;
}
return shortTwoWord;
}
return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed;
};
export const ProjectActionsButton = ({
projectRef,
directory,
@@ -651,15 +671,15 @@ export const ProjectActionsButton = ({
<button
type="button"
className={cn(
'app-region-no-drag inline-flex h-7 items-center gap-2 self-center rounded-md border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] pl-1.5 pr-2.5 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
'app-region-no-drag inline-flex h-7 shrink-0 items-center gap-2 self-center rounded-md border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] px-3 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
className
)}
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-4 w-4 text-muted-foreground" />
<span className="header-open-label">Add action</span>
<span className="header-open-label whitespace-nowrap">Add action</span>
</button>
);
}
@@ -671,6 +691,7 @@ export const ProjectActionsButton = ({
const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
const selectedButtonLabel = formatActionButtonLabel(resolvedSelected.name);
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
const selectedRunning = runningByKey[selectedRunKey];
const isStoppingSelected = selectedRunning?.status === 'stopping';
@@ -738,7 +759,7 @@ export const ProjectActionsButton = ({
return (
<div
className={cn(
'app-region-no-drag inline-flex items-center self-center rounded-md border border-[var(--interactive-border)]',
'app-region-no-drag inline-flex shrink-0 items-center self-center rounded-md border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-none overflow-hidden',
compact ? 'h-9' : 'h-7',
className
@@ -750,8 +771,8 @@ export const ProjectActionsButton = ({
disabled={isLoading || isStoppingSelected}
className={cn(
'inline-flex h-full items-center typography-ui-label font-medium text-foreground hover:bg-interactive-hover',
compact ? 'w-9 justify-center px-0' : 'gap-2 pl-2 pr-3',
'transition-colors disabled:cursor-not-allowed'
compact ? 'w-9 justify-center px-0' : 'gap-2 px-3',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed'
)}
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
>
@@ -762,7 +783,7 @@ export const ProjectActionsButton = ({
? <RiStopLine className="h-4 w-4 text-[var(--status-warning)]" />
: <SelectedIcon className="h-4 w-4" />}
</span>
{!compact ? <span className="header-open-label">{resolvedSelected.name}</span> : null}
{!compact ? <span className="header-open-label whitespace-nowrap">{selectedButtonLabel}</span> : null}
</button>
<DropdownMenu>
@@ -772,14 +793,14 @@ export const ProjectActionsButton = ({
className={cn(
compact ? 'inline-flex h-full w-8 items-center justify-center' : 'inline-flex h-full w-7 items-center justify-center',
'border-l border-[var(--interactive-border)] text-muted-foreground',
'hover:bg-interactive-hover hover:text-foreground transition-colors'
'hover:bg-interactive-hover hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
aria-label="Choose project action"
>
<RiArrowDownSLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" alignOffset={8} className="w-52 max-h-[70vh] overflow-y-auto">
<DropdownMenuContent align="center" className="w-52 max-h-[70vh] overflow-y-auto" style={{ translate: '-30px 0' }}>
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Add new action</span>
@@ -1,18 +1,23 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { isDesktopShell } from '@/lib/desktop';
export const RIGHT_SIDEBAR_CONTENT_WIDTH = 420;
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
const RIGHT_SIDEBAR_MAX_WIDTH = 860;
interface RightSidebarProps {
isOpen: boolean;
children: React.ReactNode;
className?: string;
onTopActionsHostChange?: (element: HTMLDivElement | null) => void;
}
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children }) => {
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, className, onTopActionsHostChange }) => {
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth);
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(rightSidebarWidth || 420);
@@ -34,7 +39,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
}, []);
const appliedWidth = isOpen
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420))
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH))
: 0;
const handlePointerDown = (event: React.PointerEvent) => {
@@ -97,13 +102,47 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
}
}, [isResizing]);
React.useEffect(() => {
if (!isOpen) {
onTopActionsHostChange?.(null);
}
}, [isOpen, onTopActionsHostChange]);
const handleDragStart = React.useCallback(async (event: React.MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest('.app-region-no-drag')) {
return;
}
if (target.closest('button, a, input, select, textarea')) {
return;
}
if (event.button !== 0) {
return;
}
if (!isDesktopApp) {
return;
}
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const appWindow = getCurrentWindow();
await appWindow.startDragging();
} catch (error) {
console.error('Failed to start window dragging:', error);
}
}, [isDesktopApp]);
return (
<aside
ref={sidebarRef}
className={cn(
'relative flex h-full overflow-hidden border-l border-border/40 bg-sidebar/50',
'relative flex h-full overflow-hidden border-l border-border/40',
isOpen
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar',
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
!isOpen && 'border-l-0'
!isOpen && 'border-l-0',
className,
)}
style={{
width: 'var(--oc-right-sidebar-width)',
@@ -114,11 +153,23 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
}}
aria-hidden={!isOpen || appliedWidth === 0}
>
{isOpen ? (
<div
onMouseDown={handleDragStart}
className="app-region-drag absolute inset-x-0 top-0 z-20 flex h-[var(--oc-header-height,56px)] items-center justify-end px-3"
aria-hidden
>
<div
ref={onTopActionsHostChange}
className="app-region-no-drag flex items-center gap-1"
/>
</div>
) : null}
{isOpen && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
@@ -135,6 +186,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
isResizing && 'pointer-events-none',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
style={isOpen ? { paddingTop: 'var(--oc-header-height, 56px)' } : undefined}
aria-hidden={!isOpen}
>
{isOpen ? children : null}
+11 -5
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { cn } from '@/lib/utils';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { useUIStore } from '@/stores/useUIStore';
import { isDesktopShell } from '@/lib/desktop';
export const SIDEBAR_CONTENT_WIDTH = 250;
const SIDEBAR_MIN_WIDTH = 250;
@@ -11,10 +12,12 @@ interface SidebarProps {
isOpen: boolean;
isMobile: boolean;
children: React.ReactNode;
className?: string;
}
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children }) => {
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
const { sidebarWidth, setSidebarWidth } = useUIStore();
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
@@ -115,9 +118,12 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
ref={sidebarRef}
className={cn(
'relative flex h-full overflow-hidden border-r border-border/40',
'bg-sidebar/50',
isDesktopApp
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar',
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
!isOpen && 'border-r-0'
!isOpen && 'border-r-0',
className,
)}
style={{
width: 'var(--oc-left-sidebar-width)',
@@ -131,8 +137,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
{isOpen && (
<div
className={cn(
'absolute right-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
'absolute right-0 top-0 z-20 h-full w-[3px] cursor-col-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
@@ -94,12 +94,12 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
return (
<div className={cn('w-full', className)}>
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
<div className="flex items-center justify-between gap-3 px-2 py-2.5">
<div className="min-w-0 flex items-center gap-2">
<div className="border-b border-[var(--interactive-border)]">
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
<div className="min-w-0 flex items-baseline gap-2">
<div className="typography-ui-header font-semibold text-foreground">MCP Servers</div>
{directory && (
<div className="truncate typography-ui-label text-muted-foreground">
<div className="truncate typography-micro text-muted-foreground">
{directory.split('/').pop() || directory}
</div>
)}
@@ -116,7 +116,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
</div>
</div>
<div className="max-h-64 overflow-y-auto py-1">
<div className="max-h-64 overflow-y-auto py-2">
{sortedNames.map((serverName) => {
const serverStatus = status[serverName];
const tone = statusTone(serverStatus);
@@ -127,7 +127,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
return (
<div
key={serverName}
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg hover:bg-interactive-hover/50"
className="flex items-center justify-between gap-2 px-4 py-1.5 rounded-lg hover:bg-interactive-hover/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
@@ -174,7 +174,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
})}
{sortedNames.length === 0 && (
<div className="px-2 py-3 typography-ui-label text-muted-foreground text-center">
<div className="px-4 py-5 typography-ui-label text-muted-foreground text-center">
Configure MCP servers in Opencode config.
</div>
)}
@@ -1,5 +1,5 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { Textarea } from '@/components/ui/textarea';
@@ -685,7 +685,7 @@ export const AgentsPage: React.FC = () => {
</Tooltip>
</div>
<div className="flex flex-wrap items-center gap-1">
<ButtonSmall
<Button
variant="outline"
size="xs"
onClick={() => setMode('primary')}
@@ -697,8 +697,8 @@ export const AgentsPage: React.FC = () => {
)}
>
Primary
</ButtonSmall>
<ButtonSmall
</Button>
<Button
variant="outline"
size="xs"
onClick={() => setMode('subagent')}
@@ -710,8 +710,8 @@ export const AgentsPage: React.FC = () => {
)}
>
Subagent
</ButtonSmall>
<ButtonSmall
</Button>
<Button
variant="outline"
size="xs"
onClick={() => setMode('all')}
@@ -723,7 +723,7 @@ export const AgentsPage: React.FC = () => {
)}
>
All
</ButtonSmall>
</Button>
</div>
</div>
</div>
@@ -790,7 +790,7 @@ export const AgentsPage: React.FC = () => {
className="w-16"
/>
{temperature !== undefined && (
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setTemperature(undefined)}
@@ -799,7 +799,7 @@ export const AgentsPage: React.FC = () => {
title="Clear"
>
<RiCloseLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
)}
</div>
</div>
@@ -834,7 +834,7 @@ export const AgentsPage: React.FC = () => {
className="w-16"
/>
{topP !== undefined && (
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setTopP(undefined)}
@@ -843,7 +843,7 @@ export const AgentsPage: React.FC = () => {
title="Clear"
>
<RiCloseLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
)}
</div>
</div>
@@ -876,14 +876,14 @@ export const AgentsPage: React.FC = () => {
<h3 className="typography-ui-header font-medium text-foreground">
Tool Permissions
</h3>
<ButtonSmall
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => setShowPermissionEditor((prev) => !prev)}
>
{showPermissionEditor ? 'Hide Editor' : 'Advanced Editor'}
</ButtonSmall>
</Button>
</div>
{!showPermissionEditor ? (
@@ -961,13 +961,13 @@ export const AgentsPage: React.FC = () => {
<span className="typography-micro text-muted-foreground">Pattern</span>
<span className="typography-micro font-mono text-foreground bg-[var(--surface-muted)] px-1 rounded">*</span>
{wildcardOverride && (
<ButtonSmall
<Button size="sm"
variant="ghost"
onClick={() => revertRule(permissionName, '*')}
className="px-1.5 py-0 h-5"
>
<RiSubtractLine className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</ButtonSmall>
</Button>
)}
</div>
<Select
@@ -1008,13 +1008,13 @@ export const AgentsPage: React.FC = () => {
{isAdded && <span className="typography-micro text-[var(--status-success)]">New</span>}
{isModified && <span className="typography-micro text-[var(--status-warning)]">Modified</span>}
{(isAdded || isModified) && (
<ButtonSmall
<Button size="sm"
variant="ghost"
onClick={() => isAdded ? removeRule(rule.permission, rule.pattern) : revertRule(rule.permission, rule.pattern)}
className="px-1.5 py-0 h-5"
>
<RiSubtractLine className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</ButtonSmall>
</Button>
)}
</div>
<Select
@@ -1069,9 +1069,9 @@ export const AgentsPage: React.FC = () => {
/>
<div className="flex gap-1">
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('allow')}>Allow</ButtonSmall>
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('ask')}>Ask</ButtonSmall>
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('deny')}>Deny</ButtonSmall>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('allow')}>Allow</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('ask')}>Ask</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('deny')}>Deny</Button>
</div>
</div>
</div>
@@ -1081,14 +1081,14 @@ export const AgentsPage: React.FC = () => {
{/* Save action */}
<div className="px-2 py-1">
<ButtonSmall
<Button
onClick={handleSave}
disabled={isSaving || !isDirty}
size="xs"
className="!font-normal"
>
{isSaving ? 'Saving...' : 'Save Changes'}
</ButtonSmall>
</Button>
</div>
</div>
@@ -1,6 +1,5 @@
import React, { useMemo } from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
@@ -324,13 +323,13 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span>
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
@@ -446,16 +445,17 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
variant="ghost"
onClick={closeConfirmActionDialog}
disabled={isConfirmActionPending}
>
Cancel
</ButtonLarge>
<ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}>
</Button>
<Button size="sm" onClick={handleConfirmAction} disabled={isConfirmActionPending}>
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -481,15 +481,16 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
}}
/>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
variant="ghost"
onClick={() => setRenameDialogAgent(null)}
>
Cancel
</ButtonLarge>
<ButtonLarge onClick={handleRenameAgent}>
</Button>
<Button size="sm" onClick={handleRenameAgent}>
Rename
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -563,12 +564,12 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && (
@@ -630,8 +630,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{/* Favorites Section */}
{filteredFavorites.length > 0 && (
<>
<DropdownMenuLabel style={{ backgroundColor: 'var(--surface-elevated)' }} className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30">
<div>
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
<RiStarFill className="h-4 w-4 text-primary" />
Favorites
</DropdownMenuLabel>
@@ -639,14 +639,14 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const idx = currentFlatIndex++;
return renderModelRow(model, providerID, modelID, 'fav', idx, selectedIndex === idx);
})}
</>
</div>
)}
{/* Recents Section */}
{filteredRecents.length > 0 && (
<>
<div>
{filteredFavorites.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel style={{ backgroundColor: 'var(--surface-elevated)' }} className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30">
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
<RiTimeLine className="h-4 w-4" />
Recent
</DropdownMenuLabel>
@@ -654,7 +654,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const idx = currentFlatIndex++;
return renderModelRow(model, providerID, modelID, 'recent', idx, selectedIndex === idx);
})}
</>
</div>
)}
{/* Separator before providers */}
@@ -664,9 +664,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{/* All Providers - Flat List */}
{filteredProviders.map((provider, index) => (
<React.Fragment key={provider.id}>
<div key={provider.id}>
{index > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel style={{ backgroundColor: 'var(--surface-elevated)' }} className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30">
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
<ProviderLogo
providerId={provider.id}
className="h-4 w-4 flex-shrink-0"
@@ -677,7 +677,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const idx = currentFlatIndex++;
return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, selectedIndex === idx);
})}
</React.Fragment>
</div>
))}
</div>
</ScrollableOverlay>
@@ -1,5 +1,5 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
@@ -321,14 +321,14 @@ export const CommandsPage: React.FC = () => {
{/* Save action */}
<div className="px-2 py-1">
<ButtonSmall
<Button
onClick={handleSave}
disabled={isSaving || !isDirty}
size="xs"
className="!font-normal"
>
{isSaving ? 'Saving...' : 'Save Changes'}
</ButtonSmall>
</Button>
</div>
</div>
@@ -1,6 +1,5 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
@@ -221,13 +220,13 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {commandOnlyItems.length}</span>
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
@@ -310,16 +309,17 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
</DialogDescription>
</DialogHeader>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
variant="ghost"
onClick={closeConfirmActionDialog}
disabled={isConfirmActionPending}
>
Cancel
</ButtonLarge>
<ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}>
</Button>
<Button size="sm" onClick={handleConfirmAction} disabled={isConfirmActionPending}>
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -345,15 +345,16 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
}}
/>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
variant="ghost"
onClick={() => setRenameDialogCommand(null)}
>
Cancel
</ButtonLarge>
<ButtonLarge onClick={handleRenameCommand}>
</Button>
<Button size="sm" onClick={handleRenameCommand}>
Rename
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -422,12 +423,12 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && (
@@ -1,6 +1,5 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import {
@@ -351,7 +350,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Auth Method</span>
<div className="flex items-center gap-1">
<ButtonSmall
<Button size="sm"
type="button"
variant="outline"
onClick={() => setAuthType('ssh')}
@@ -362,8 +361,8 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
)}
>
<RiLock2Line className="w-3.5 h-3.5 mr-1" /> SSH
</ButtonSmall>
<ButtonSmall
</Button>
<Button size="sm"
type="button"
variant="outline"
onClick={() => setAuthType('token')}
@@ -374,7 +373,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
)}
>
<RiKeyLine className="w-3.5 h-3.5 mr-1" /> Token
</ButtonSmall>
</Button>
</div>
</div>
@@ -467,7 +466,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
Cancel
</Button>
<Button size="sm" onClick={() => void handleConfirmDelete()} disabled={isDeleting} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
<Button size="sm" variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeleting}>
Delete
</Button>
</DialogFooter>
@@ -1,5 +1,4 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { toast } from '@/components/ui';
import {
Dialog,
@@ -10,7 +9,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import {
DropdownMenu,
DropdownMenuContent,
@@ -124,9 +122,9 @@ export const GitPage: React.FC = () => {
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Identities</h3>
</div>
<ButtonSmall variant="outline" onClick={() => openEditor('new')}>
<Button size="sm" variant="outline" onClick={() => openEditor('new')}>
<RiAddLine className="w-3.5 h-3.5 mr-1" /> New
</ButtonSmall>
</Button>
</div>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
@@ -213,9 +211,9 @@ export const GitPage: React.FC = () => {
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
Cancel
</Button>
<ButtonLarge onClick={() => void handleConfirmDelete()} disabled={isDeletePending} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
<Button size="sm" variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeletePending}>
Delete
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -340,10 +338,10 @@ const DiscoveredRow: React.FC<DiscoveredRowProps> = ({ credential, onImport, has
{isRepoSpecific ? credential.host : credential.username}
</span>
</div>
<ButtonSmall variant="ghost" onClick={onImport} className="gap-1 shrink-0">
<Button size="sm" variant="ghost" onClick={onImport} className="gap-1 shrink-0">
<RiDownloadLine className="h-3 w-3" />
Import
</ButtonSmall>
</Button>
</div>
);
};
@@ -1,7 +1,5 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { ButtonSmall } from '@/components/ui/button-small';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -110,7 +108,7 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({ value, onChange }) =>
return (
<div className="space-y-2">
<div className="flex items-center justify-end gap-2">
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
@@ -120,7 +118,7 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({ value, onChange }) =>
>
<RiClipboardLine className="h-3 w-3" />
Paste command
</ButtonSmall>
</Button>
</div>
<Textarea
@@ -252,7 +250,7 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
<span className="typography-micro text-muted-foreground w-32 shrink-0">Key</span>
<span className="typography-micro text-muted-foreground">Value</span>
</div>
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
@@ -262,7 +260,7 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
>
<RiClipboardLine className="h-3 w-3" />
Paste .env
</ButtonSmall>
</Button>
</div>
{/* Rows */}
@@ -299,18 +297,18 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
</button>
</div>
{/* Remove */}
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 shrink-0 text-muted-foreground hover:text-[var(--status-error)]"
onClick={() => removeRow(idx)}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
))}
</div>
<ButtonSmall
<Button
variant="outline"
size="xs"
className="!font-normal gap-1.5"
@@ -319,7 +317,7 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
>
<RiAddLine className="h-3.5 w-3.5" />
Add variable
</ButtonSmall>
</Button>
{hasSensitiveValues && (
<p className="typography-micro text-muted-foreground/60">
@@ -535,7 +533,7 @@ export const McpPage: React.FC = () => {
{isNewServer ? 'Configure a new MCP server' : `${mcpType === 'local' ? 'Local · stdio' : 'Remote · SSE'} transport`}
</p>
{!isNewServer && (
<ButtonSmall
<Button
variant={isConnected ? 'outline' : 'default'}
size="xs"
className="!font-normal"
@@ -543,7 +541,7 @@ export const McpPage: React.FC = () => {
disabled={isConnecting || !enabled}
>
{isConnecting ? 'Working...' : isConnected ? 'Disconnect' : 'Connect'}
</ButtonSmall>
</Button>
)}
</div>
</div>
@@ -618,7 +616,7 @@ export const McpPage: React.FC = () => {
<div className="flex min-w-0 flex-col gap-1.5">
<span className="typography-ui-label text-foreground">Transport Mode</span>
<div className="flex flex-wrap items-center gap-1">
<ButtonSmall
<Button
variant="outline"
size="xs"
onClick={() => setMcpType('local')}
@@ -630,8 +628,8 @@ export const McpPage: React.FC = () => {
)}
>
Local · stdio
</ButtonSmall>
<ButtonSmall
</Button>
<Button
variant="outline"
size="xs"
onClick={() => setMcpType('remote')}
@@ -643,7 +641,7 @@ export const McpPage: React.FC = () => {
)}
>
Remote · SSE
</ButtonSmall>
</Button>
</div>
</div>
</div>
@@ -693,23 +691,23 @@ export const McpPage: React.FC = () => {
{/* Actions */}
<div className="flex items-center gap-2 px-2 py-1">
<ButtonSmall
<Button
onClick={handleSave}
disabled={isSaving || (!isDirty && !isNewServer)}
size="xs"
className="!font-normal"
>
{isSaving ? 'Saving...' : isNewServer ? 'Create' : 'Save Changes'}
</ButtonSmall>
</Button>
{!isNewServer && (
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="!font-normal text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setShowDeleteConfirm(true)}
>
Delete
</ButtonSmall>
</Button>
)}
</div>
</div>
@@ -736,9 +734,9 @@ export const McpPage: React.FC = () => {
>
Cancel
</Button>
<ButtonLarge onClick={handleDelete} disabled={isDeleting}>
<Button size="sm" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting…' : 'Delete'}
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,6 +1,5 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -130,14 +129,14 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
<span className="typography-meta text-muted-foreground">
Total {mcpServers.length}
</span>
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
title="Add MCP server"
>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
@@ -198,9 +197,9 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
<DropdownMenuTrigger asChild>
<ButtonSmall variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<Button size="xs" variant="ghost" className="flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<RiMore2Line className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
@@ -268,9 +267,9 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
<DropdownMenuTrigger asChild>
<ButtonSmall variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<Button size="xs" variant="ghost" className="flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<RiMore2Line className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
@@ -308,16 +307,17 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
</DialogDescription>
</DialogHeader>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting}
>
Cancel
</ButtonLarge>
<ButtonLarge onClick={handleDelete} disabled={isDeleting}>
</Button>
<Button size="sm" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting…' : 'Delete'}
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -5,7 +5,7 @@ import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { useDeviceInfo } from '@/lib/device';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
@@ -155,26 +155,26 @@ export const AboutSettings: React.FC = () => {
)}
{!updateStore.checking && updateStore.available && (
<ButtonSmall
<Button size="sm"
variant="default"
onClick={() => setUpdateDialogOpen(true)}
>
<RiDownloadLine className="h-4 w-4 mr-1" />
Update to {updateStore.info?.version}
</ButtonSmall>
</Button>
)}
{!updateStore.checking && !updateStore.available && !updateStore.error && (
<span className="typography-meta text-muted-foreground">Up to date</span>
)}
<ButtonSmall
<Button size="sm"
variant="outline"
onClick={() => updateStore.checkForUpdates()}
disabled={updateStore.checking}
>
Check for updates
</ButtonSmall>
</Button>
</div>
</div>
@@ -1,6 +1,5 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { toast } from '@/components/ui';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
@@ -304,18 +303,18 @@ export const GitHubSettings: React.FC = () => {
</div>
</div>
<ButtonSmall variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
<Button size="sm" variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
Disconnect
</ButtonSmall>
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-4 px-4 py-4">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Not Connected</span>
</div>
<ButtonSmall variant="default" onClick={startConnect} disabled={isBusy}>
<Button size="sm" variant="default" onClick={startConnect} disabled={isBusy}>
Connect GitHub
</ButtonSmall>
</Button>
</div>
)}
@@ -359,13 +358,13 @@ export const GitHubSettings: React.FC = () => {
{isCurrent ? (
<span className="typography-micro text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">Active</span>
) : (
<ButtonSmall
<Button size="sm"
variant="ghost"
onClick={() => activateAccount(account.id)}
disabled={isBusy}
>
Switch to
</ButtonSmall>
</Button>
)}
</div>
);
@@ -378,14 +377,14 @@ export const GitHubSettings: React.FC = () => {
{connected && (
<div className="mt-2 px-2 pb-2">
<ButtonSmall
<Button size="sm"
variant="outline"
onClick={startConnect}
disabled={isBusy}
className={cn(isMobile ? 'w-full' : undefined)}
>
Add Account
</ButtonSmall>
</Button>
</div>
)}
@@ -413,12 +412,12 @@ export const GitHubSettings: React.FC = () => {
<span className="typography-micro text-muted-foreground animate-pulse">
Waiting for approval (auto-refresh)
</span>
<ButtonSmall variant="ghost" disabled={isBusy} onClick={() => {
<Button size="sm" variant="ghost" disabled={isBusy} onClick={() => {
stopPolling();
setFlow(null);
}}>
Cancel
</ButtonSmall>
</Button>
</div>
</div>
)}
@@ -1,5 +1,5 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiInformationLine } from '@remixicon/react';
@@ -132,7 +132,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">Keyboard Shortcuts</h3>
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -146,7 +146,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
}}
>
Reset All
</ButtonSmall>
</Button>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
@@ -166,8 +166,8 @@ export const KeyboardShortcutsSettings: React.FC = () => {
This combo is already used by another shortcut. Overwrite and clear that other mapping?
</span>
<div className="flex gap-2 shrink-0">
<ButtonSmall type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>Overwrite</ButtonSmall>
<ButtonSmall type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</ButtonSmall>
<Button type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>Overwrite</Button>
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</Button>
</div>
</div>
)}
@@ -233,7 +233,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
}}
className="h-7 w-40 min-w-0 typography-ui-label text-center"
/>
<ButtonSmall
<Button
type="button"
variant="secondary"
size="xs"
@@ -249,10 +249,10 @@ export const KeyboardShortcutsSettings: React.FC = () => {
disabled={!hasDraft}
>
Save
</ButtonSmall>
<ButtonSmall type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
</Button>
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
Reset
</ButtonSmall>
</Button>
</div>
</div>
);
@@ -11,7 +11,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { GridLoader } from '@/components/ui/grid-loader';
import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
@@ -800,7 +800,7 @@ export const NotificationSettings: React.FC = () => {
step={50}
className="w-20 tabular-nums"
/>
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setSummaryThreshold(DEFAULT_SUMMARY_THRESHOLD)}
@@ -810,7 +810,7 @@ export const NotificationSettings: React.FC = () => {
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
<div className="flex items-center gap-8 py-1.5">
@@ -827,7 +827,7 @@ export const NotificationSettings: React.FC = () => {
step={10}
className="w-20 tabular-nums"
/>
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setSummaryLength(DEFAULT_SUMMARY_LENGTH)}
@@ -837,7 +837,7 @@ export const NotificationSettings: React.FC = () => {
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
</>
@@ -856,7 +856,7 @@ export const NotificationSettings: React.FC = () => {
step={10}
className="w-20 tabular-nums"
/>
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setMaxLastMessageLength(DEFAULT_MAX_LAST_MESSAGE_LENGTH)}
@@ -866,7 +866,7 @@ export const NotificationSettings: React.FC = () => {
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
)}
@@ -99,7 +99,7 @@ const ShortcutsSectionContent: React.FC = () => {
return <KeyboardShortcutsSettings />;
};
// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile), Nav Rail
// Visual section: Theme Mode, Font Size, Spacing, Input Bar Offset (mobile), Nav Rail
const VisualSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime();
return <OpenChamberVisualSettings visibleSettings={[
@@ -108,9 +108,8 @@ const VisualSectionContent: React.FC = () => {
'fontSize',
'terminalFontSize',
'spacing',
'cornerRadius',
'inputBarOffset',
...(!isVSCode ? ['terminalQuickKeys' as const, 'navRail' as const] : []),
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
]} />;
};
@@ -7,7 +7,7 @@ import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { cn, getModifierLabel } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { NumberInput } from '@/components/ui/number-input';
import { Radio } from '@/components/ui/radio';
@@ -143,7 +143,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck';
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -173,8 +173,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setTerminalFontSize = useUIStore(state => state.setTerminalFontSize);
const padding = useUIStore(state => state.padding);
const setPadding = useUIStore(state => state.setPadding);
const cornerRadius = useUIStore(state => state.cornerRadius);
const setCornerRadius = useUIStore(state => state.setCornerRadius);
const inputBarOffset = useUIStore(state => state.inputBarOffset);
const setInputBarOffset = useUIStore(state => state.setInputBarOffset);
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
@@ -195,10 +193,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setShowExpandedBashTools = useUIStore(state => state.setShowExpandedBashTools);
const showExpandedEditTools = useUIStore(state => state.showExpandedEditTools);
const setShowExpandedEditTools = useUIStore(state => state.setShowExpandedEditTools);
const isNavRailExpanded = useUIStore(state => state.isNavRailExpanded);
const setNavRailExpanded = useUIStore(state => state.setNavRailExpanded);
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen);
const {
themeMode,
setThemeMode,
@@ -214,7 +211,14 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const [themesReloading, setThemesReloading] = React.useState(false);
const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0);
const shouldAnimateChatPreview = isSettingsDialogOpen
&& (visibleSettings ? visibleSettings.includes('chatRenderMode') : true);
React.useEffect(() => {
if (!shouldAnimateChatPreview) {
return;
}
const intervalId = setInterval(() => {
setChatRenderPreviewTick((prev) => (prev + 1) % 24);
}, 420);
@@ -222,7 +226,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
return () => {
clearInterval(intervalId);
};
}, []);
}, [shouldAnimateChatPreview]);
const handleUserMessageRenderingModeChange = React.useCallback((mode: 'markdown' | 'plain') => {
setUserMessageRenderingMode(mode);
@@ -305,8 +309,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const isVSCode = isVSCodeRuntime();
const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName')) && !isVSCode;
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile);
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset');
const hasNavigationSettings = shouldShow('terminalQuickKeys') && !isMobile;
const hasBehaviorSettings = shouldShow('mermaidRendering')
|| shouldShow('userMessageRendering')
|| shouldShow('chatRenderMode')
@@ -402,7 +406,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<span className="typography-ui-header font-medium text-foreground">Color Mode</span>
<div className="flex flex-wrap items-center gap-1">
{THEME_MODE_OPTIONS.map((option) => (
<ButtonSmall
<Button
key={option.value}
variant="outline"
size="xs"
@@ -415,7 +419,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
onClick={() => setThemeMode(option.value)}
>
{option.label}
</ButtonSmall>
</Button>
))}
</div>
</div>
@@ -516,7 +520,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
maxLength={64}
aria-label="PWA install app name"
/>
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => {
@@ -528,7 +532,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
)}
@@ -558,7 +562,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
aria-label="Font size percentage"
className="w-16"
/>
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setFontSize(100)}
@@ -568,7 +572,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
)}
@@ -587,7 +591,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
step={1}
className="w-16"
/>
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setTerminalFontSize(13)}
@@ -597,7 +601,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
)}
@@ -616,7 +620,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
step={5}
className="w-16"
/>
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setPadding(100)}
@@ -626,36 +630,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
)}
{shouldShow('cornerRadius') && (
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<span className="typography-ui-label text-foreground">Corner Radius</span>
</div>
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
<NumberInput
value={cornerRadius}
onValueChange={setCornerRadius}
min={0}
max={32}
step={1}
className="w-16"
/>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => setCornerRadius(12)}
disabled={cornerRadius === 12}
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset corner radius"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
)}
@@ -684,7 +659,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
step={5}
className="w-16"
/>
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setInputBarOffset(0)}
@@ -694,7 +669,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
)}
@@ -710,38 +685,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<div className="space-y-3">
<section className="px-2 pb-2 pt-0">
<h4 className="typography-ui-header font-medium text-foreground">Navigation</h4>
{shouldShow('navRail') && !isMobile && (
<div
className="group mt-1.5 flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
onClick={() => setNavRailExpanded(!isNavRailExpanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setNavRailExpanded(!isNavRailExpanded);
}
}}
>
<Checkbox
checked={isNavRailExpanded}
onChange={setNavRailExpanded}
ariaLabel="Expand project rail by default"
/>
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label text-foreground">Expand project rail</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Show project names in the left rail when multiple projects are open. Auto-collapses with a single project.
</TooltipContent>
</Tooltip>
</div>
</div>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
@@ -1,5 +1,5 @@
import * as React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiFolderLine, RiInformationLine } from '@remixicon/react';
@@ -111,7 +111,7 @@ export const OpenCodeCliSettings: React.FC = () => {
disabled={isLoading || isSaving}
className="h-7 min-w-0 flex-1 font-mono text-xs"
/>
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -122,7 +122,7 @@ export const OpenCodeCliSettings: React.FC = () => {
title="Browse"
>
<RiFolderLine className="h-4 w-4" />
</ButtonSmall>
</Button>
</div>
</div>
@@ -133,7 +133,7 @@ export const OpenCodeCliSettings: React.FC = () => {
</div>
<div className="flex justify-start py-1.5">
<ButtonSmall
<Button
type="button"
size="xs"
onClick={handleSaveAndReload}
@@ -141,7 +141,7 @@ export const OpenCodeCliSettings: React.FC = () => {
className="shrink-0 !font-normal"
>
{isSaving ? 'Saving…' : 'Save + Reload'}
</ButtonSmall>
</Button>
</div>
</section>
</div>
@@ -3,7 +3,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { RiInformationLine, RiRestartLine } from '@remixicon/react';
import { toast } from '@/components/ui';
import { NumberInput } from '@/components/ui/number-input';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
@@ -90,7 +90,7 @@ export const SessionRetentionSettings: React.FC = () => {
className="w-20 tabular-nums"
/>
<span className="typography-ui-label text-muted-foreground">days</span>
<ButtonSmall
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setAutoDeleteAfterDays(DEFAULT_RETENTION_DAYS)}
@@ -100,7 +100,7 @@ export const SessionRetentionSettings: React.FC = () => {
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
</section>
@@ -111,7 +111,7 @@ export const SessionRetentionSettings: React.FC = () => {
<p className="typography-meta text-foreground font-medium">Manual Cleanup</p>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -120,7 +120,7 @@ export const SessionRetentionSettings: React.FC = () => {
className="!font-normal"
>
{isRunning ? 'Cleaning up...' : 'Run cleanup now'}
</ButtonSmall>
</Button>
</div>
</div>
<p className="typography-meta text-muted-foreground">
@@ -18,7 +18,7 @@ import {
RiRestartLine,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { GridLoader } from '@/components/ui/grid-loader';
import { Input } from '@/components/ui/input';
@@ -1121,7 +1121,7 @@ export const TunnelSettings: React.FC = () => {
{TUNNEL_MODE_OPTIONS.map((option) => (
<Tooltip key={option.value} delayDuration={700}>
<TooltipTrigger asChild>
<ButtonSmall
<Button
variant="outline"
size="xs"
className={cn(
@@ -1136,7 +1136,7 @@ export const TunnelSettings: React.FC = () => {
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
>
{option.label}
</ButtonSmall>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{option.tooltip}
@@ -1217,7 +1217,7 @@ export const TunnelSettings: React.FC = () => {
<div className="mb-1 flex items-center justify-between gap-3">
<p className="typography-ui-label text-foreground">Saved managed remote tunnels</p>
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="!font-normal"
@@ -1226,7 +1226,7 @@ export const TunnelSettings: React.FC = () => {
>
<RiAddLine className="h-3.5 w-3.5" />
Add
</ButtonSmall>
</Button>
</div>
{managedRemoteTunnelPresets.length > 0 ? (
@@ -1263,7 +1263,7 @@ export const TunnelSettings: React.FC = () => {
<span className="typography-ui-label min-w-0 flex-1 truncate text-foreground">{preset.name}</span>
</CollapsibleTrigger>
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="h-7 w-7 p-0 text-muted-foreground hover:text-[var(--status-error)]"
@@ -1274,7 +1274,7 @@ export const TunnelSettings: React.FC = () => {
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
<CollapsibleContent className="pt-1.5">
@@ -1305,7 +1305,7 @@ export const TunnelSettings: React.FC = () => {
disabled={state === 'starting' || state === 'stopping'}
/>
<div className="flex items-center justify-end">
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="!font-normal"
@@ -1320,7 +1320,7 @@ export const TunnelSettings: React.FC = () => {
}}
>
Save token
</ButtonSmall>
</Button>
</div>
</div>
</CollapsibleContent>
@@ -1363,7 +1363,7 @@ export const TunnelSettings: React.FC = () => {
</p>
)}
<div className="flex items-center gap-2">
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="!font-normal"
@@ -1373,8 +1373,8 @@ export const TunnelSettings: React.FC = () => {
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
>
Save
</ButtonSmall>
<ButtonSmall
</Button>
<Button
variant="ghost"
size="xs"
className="!font-normal"
@@ -1387,7 +1387,7 @@ export const TunnelSettings: React.FC = () => {
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
>
Cancel
</ButtonSmall>
</Button>
</div>
</div>
)}
@@ -1442,7 +1442,7 @@ export const TunnelSettings: React.FC = () => {
className="h-7"
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
/>
<ButtonSmall
<Button
variant="outline"
size="xs"
className="h-7 w-7 p-0"
@@ -1453,9 +1453,9 @@ export const TunnelSettings: React.FC = () => {
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
>
<RiFolderLine className="size-3.5" />
</ButtonSmall>
</Button>
{managedLocalConfigPath && (
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="h-7 w-7 p-0"
@@ -1466,7 +1466,7 @@ export const TunnelSettings: React.FC = () => {
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
>
<RiCloseLine className="size-3.5" />
</ButtonSmall>
</Button>
)}
</div>
<p className="typography-meta text-muted-foreground/70">
@@ -1566,7 +1566,7 @@ export const TunnelSettings: React.FC = () => {
</div>
)}
<ButtonSmall
<Button size="sm"
variant="outline"
onClick={handleStart}
disabled={
@@ -1580,7 +1580,7 @@ export const TunnelSettings: React.FC = () => {
{state === 'starting'
? <><RiLoader4Line className="size-3.5 animate-spin" /> Starting tunnel...</>
: 'Start Tunnel'}
</ButtonSmall>
</Button>
</div>
)}
@@ -1610,12 +1610,12 @@ export const TunnelSettings: React.FC = () => {
<code className="typography-code flex-1 truncate rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
{tunnelInfo.connectUrl}
</code>
<ButtonSmall variant="ghost" onClick={handleCopyUrl} className="shrink-0 gap-1.5">
<Button size="sm" variant="ghost" onClick={handleCopyUrl} className="shrink-0 gap-1.5">
{copied
? <RiCheckLine className="size-3.5 text-[var(--status-success)]" />
: <RiFileCopyLine className="size-3.5" />}
{copied ? 'Copied' : 'Copy'}
</ButtonSmall>
</Button>
</div>
<p className="typography-meta mt-1 text-muted-foreground/70">
Expires: {tunnelInfo.bootstrapExpiresAt ? remainingText : 'Never'}
@@ -1634,7 +1634,7 @@ export const TunnelSettings: React.FC = () => {
<div className="pt-1">
<div className="flex flex-wrap items-center gap-2">
<ButtonSmall
<Button size="sm"
variant="outline"
onClick={handleStart}
disabled={state === 'stopping' || isSavingMode || (tunnelMode === 'managed-local' && isManagedLocalConfigPathInvalid)}
@@ -1642,9 +1642,9 @@ export const TunnelSettings: React.FC = () => {
>
<RiRestartLine className="size-3.5" />
New connect link
</ButtonSmall>
</Button>
<ButtonSmall
<Button size="sm"
variant="ghost"
onClick={handleStop}
disabled={state === 'stopping' || isSavingMode}
@@ -1653,7 +1653,7 @@ export const TunnelSettings: React.FC = () => {
{state === 'stopping'
? <><RiLoader4Line className="size-3.5 animate-spin" /> Stopping...</>
: 'Stop Tunnel'}
</ButtonSmall>
</Button>
</div>
</div>
</section>
@@ -1662,7 +1662,7 @@ export const TunnelSettings: React.FC = () => {
{state === 'error' && errorMessage && (
<section className="space-y-3 px-2 pb-2 pt-0">
<p className="typography-meta text-[var(--status-error)]">{errorMessage}</p>
<ButtonSmall variant="ghost" onClick={handleStart}>Retry</ButtonSmall>
<Button size="sm" variant="ghost" onClick={handleStart}>Retry</Button>
</section>
)}
</div>
@@ -11,7 +11,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { NumberInput } from '@/components/ui/number-input';
import { RiPlayLine, RiStopLine, RiCloseLine, RiAppleLine, RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -363,7 +363,7 @@ export const VoiceSettings: React.FC = () => {
</Tooltip>
</div>
<div className="flex flex-wrap items-center gap-1">
<ButtonSmall
<Button
variant="outline"
size="xs"
onClick={() => setVoiceProvider('browser')}
@@ -375,8 +375,8 @@ export const VoiceSettings: React.FC = () => {
)}
>
Browser
</ButtonSmall>
<ButtonSmall
</Button>
<Button
variant="outline"
size="xs"
onClick={() => setVoiceProvider('openai')}
@@ -388,9 +388,9 @@ export const VoiceSettings: React.FC = () => {
)}
>
OpenAI
</ButtonSmall>
</Button>
{isSayAvailable && (
<ButtonSmall
<Button
variant="outline"
size="xs"
onClick={() => setVoiceProvider('say')}
@@ -403,7 +403,7 @@ export const VoiceSettings: React.FC = () => {
>
<RiAppleLine className="w-3.5 h-3.5 mr-0.5" />
Say
</ButtonSmall>
</Button>
)}
</div>
</div>
@@ -455,9 +455,9 @@ export const VoiceSettings: React.FC = () => {
))}
</SelectContent>
</Select>
<ButtonSmall variant="ghost" className="h-7 w-7 px-0" onClick={previewOpenAIVoice} title="Preview">
<Button size="xs" variant="ghost" onClick={previewOpenAIVoice} title="Preview">
{isOpenAIPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
</ButtonSmall>
</Button>
</>
)}
@@ -473,9 +473,9 @@ export const VoiceSettings: React.FC = () => {
))}
</SelectContent>
</Select>
<ButtonSmall variant="ghost" className="h-7 w-7 px-0" onClick={previewVoice} title="Preview">
<Button size="xs" variant="ghost" onClick={previewVoice} title="Preview">
{isPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
</ButtonSmall>
</Button>
</>
)}
@@ -492,9 +492,9 @@ export const VoiceSettings: React.FC = () => {
))}
</SelectContent>
</Select>
<ButtonSmall variant="ghost" className="h-7 w-7 px-0" onClick={previewBrowserVoice} title="Preview">
<Button size="xs" variant="ghost" onClick={previewBrowserVoice} title="Preview">
{isBrowserPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
</ButtonSmall>
</Button>
</>
)}
</div>
@@ -1,6 +1,6 @@
import React from 'react';
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -306,7 +306,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
</button>
</div>
))}
<ButtonSmall
<Button
type="button"
variant="ghost"
size="xs"
@@ -315,7 +315,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
>
<RiAddLine className="h-3.5 w-3.5" />
Add command
</ButtonSmall>
</Button>
</div>
)}
</div>
@@ -7,7 +7,7 @@ import {
RiInformationLine,
RiPlayLine,
} from '@remixicon/react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
@@ -198,10 +198,10 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
<p className="typography-meta text-muted-foreground">Per-project commands shown in header next to project name.</p>
</div>
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
<RiAddLine className="h-3.5 w-3.5" />
Add action
</ButtonSmall>
</Button>
</div>
<section className="pb-2 pt-0 space-y-2">
@@ -248,7 +248,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
</div>
</CollapsibleTrigger>
<ButtonSmall
<Button
type="button"
variant="ghost"
size="xs"
@@ -256,7 +256,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
onClick={() => handleRemoveAction(action.id)}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
<CollapsibleContent className="pt-1.5">
@@ -418,7 +418,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
{validationError ? (
<p className="typography-meta mb-2 text-[var(--status-warning)]">{validationError}</p>
) : null}
<ButtonSmall
<Button
type="button"
size="xs"
className="!font-normal"
@@ -426,7 +426,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
disabled={!canSave}
>
{isSaving ? 'Saving...' : 'Save Actions'}
</ButtonSmall>
</Button>
</div>
</section>
</div>
@@ -1,6 +1,6 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
@@ -399,7 +399,7 @@ export const ProjectsPage: React.FC = () => {
placeholder="#000000"
className="h-7 w-[8rem]"
/>
<ButtonSmall
<Button
type="button"
size="xs"
variant="outline"
@@ -410,21 +410,21 @@ export const ProjectsPage: React.FC = () => {
disabled={!iconBackground}
>
<RiCloseLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
{!hasCustomIcon && (
<>
<ButtonSmall
<Button
size="xs"
className="h-6 !font-normal"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
>
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
</ButtonSmall>
<ButtonSmall
</Button>
<Button
size="xs"
className="h-6 !font-normal"
variant="outline"
@@ -432,11 +432,11 @@ export const ProjectsPage: React.FC = () => {
disabled={isDiscoveringIcon}
>
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
</ButtonSmall>
</Button>
</>
)}
{hasRemovableImageIcon && (
<ButtonSmall
<Button
size="xs"
className="!font-normal"
variant="outline"
@@ -444,10 +444,10 @@ export const ProjectsPage: React.FC = () => {
disabled={isRemovingCustomIcon}
>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
</ButtonSmall>
</Button>
)}
{pendingRemoveImageIcon && (
<ButtonSmall
<Button
size="xs"
className="!font-normal"
variant="outline"
@@ -455,7 +455,7 @@ export const ProjectsPage: React.FC = () => {
disabled={isRemovingCustomIcon}
>
Undo Remove
</ButtonSmall>
</Button>
)}
</div>
</div>
@@ -463,14 +463,14 @@ export const ProjectsPage: React.FC = () => {
</section>
<div className="mt-0.5 px-2 py-1">
<ButtonSmall
<Button
onClick={handleSave}
disabled={!hasChanges || name.trim().length === 0 || isUploadingIcon || isRemovingCustomIcon}
size="xs"
className="!font-normal"
>
Save Changes
</ButtonSmall>
</Button>
</div>
</div>
@@ -3,7 +3,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
@@ -653,14 +653,14 @@ export const ProvidersPage: React.FC = () => {
placeholder="sk-..."
className="flex-1 font-mono text-xs"
/>
<ButtonSmall
<Button
size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(candidateProviderId)}
disabled={authBusyKey === `api:${candidateProviderId}`}
>
{authBusyKey === `api:${candidateProviderId}` ? 'Saving...' : 'Save Key'}
</ButtonSmall>
</Button>
</div>
</div>
@@ -693,7 +693,7 @@ export const ProvidersPage: React.FC = () => {
</div>
)}
</div>
<ButtonSmall
<Button
variant="outline"
size="xs"
className="!font-normal"
@@ -701,7 +701,7 @@ export const ProvidersPage: React.FC = () => {
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
>
Connect
</ButtonSmall>
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
@@ -713,7 +713,7 @@ export const ProvidersPage: React.FC = () => {
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</ButtonSmall>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</Button>
</div>
)}
@@ -721,8 +721,8 @@ export const ProvidersPage: React.FC = () => {
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</ButtonSmall>
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</ButtonSmall>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
</div>
</div>
)}
@@ -740,14 +740,14 @@ export const ProvidersPage: React.FC = () => {
placeholder="Paste authorization code"
className="font-mono text-xs"
/>
<ButtonSmall
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(candidateProviderId, index)}
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
>
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? 'Saving...' : 'Complete'}
</ButtonSmall>
</Button>
</div>
)}
</div>
@@ -810,14 +810,14 @@ export const ProvidersPage: React.FC = () => {
<div className="mb-8">
<div className="mb-1 px-1 flex items-center justify-between gap-2">
<h3 className="typography-ui-header font-medium text-foreground">Authentication</h3>
<ButtonSmall
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => setShowAuthPanel((prev) => !prev)}
>
{showAuthPanel ? 'Hide' : 'Reconnect'}
</ButtonSmall>
</Button>
</div>
<section className="px-2 pb-2 pt-0">
@@ -856,14 +856,14 @@ export const ProvidersPage: React.FC = () => {
placeholder="sk-..."
className="flex-1 font-mono text-xs"
/>
<ButtonSmall
<Button
size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(selectedProvider.id)}
disabled={authBusyKey === `api:${selectedProvider.id}`}
>
{authBusyKey === `api:${selectedProvider.id}` ? 'Saving...' : 'Save Key'}
</ButtonSmall>
</Button>
</div>
</div>
@@ -886,7 +886,7 @@ export const ProvidersPage: React.FC = () => {
</div>
)}
</div>
<ButtonSmall
<Button
variant="outline"
size="xs"
className="!font-normal"
@@ -894,7 +894,7 @@ export const ProvidersPage: React.FC = () => {
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
>
Connect
</ButtonSmall>
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
@@ -906,7 +906,7 @@ export const ProvidersPage: React.FC = () => {
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</ButtonSmall>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</Button>
</div>
)}
@@ -914,8 +914,8 @@ export const ProvidersPage: React.FC = () => {
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</ButtonSmall>
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</ButtonSmall>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
</div>
</div>
)}
@@ -933,14 +933,14 @@ export const ProvidersPage: React.FC = () => {
placeholder="Paste authorization code"
className="font-mono text-xs"
/>
<ButtonSmall
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(selectedProvider.id, index)}
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
>
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? 'Saving...' : 'Complete'}
</ButtonSmall>
</Button>
</div>
)}
</div>
@@ -976,7 +976,7 @@ export const ProvidersPage: React.FC = () => {
)}
</div>
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="!font-normal text-[var(--status-error)] hover:text-[var(--status-error)]"
@@ -984,7 +984,7 @@ export const ProvidersPage: React.FC = () => {
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
>
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting...' : 'Disconnect'}
</ButtonSmall>
</Button>
</div>
</section>
</div>
@@ -1001,7 +1001,7 @@ export const ProvidersPage: React.FC = () => {
)}
</h3>
<div className="flex items-center gap-1">
<ButtonSmall
<Button
variant="outline"
size="xs"
className="!font-normal"
@@ -1013,15 +1013,15 @@ export const ProvidersPage: React.FC = () => {
}}
>
Hide all
</ButtonSmall>
<ButtonSmall
</Button>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => showAllModels(selectedProvider.id)}
>
Show all
</ButtonSmall>
</Button>
</div>
</div>
@@ -1,7 +1,7 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { RiAddLine, RiStackLine } from '@remixicon/react';
@@ -110,7 +110,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {providers.length}</span>
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={() => {
@@ -121,7 +121,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
title="Connect provider"
>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
@@ -1,5 +1,5 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { Switch } from '@/components/ui/switch';
@@ -701,7 +701,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
<div className="typography-micro text-muted-foreground">{candidate.source} config</div>
</div>
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -709,7 +709,7 @@ export const RemoteInstancesPage: React.FC = () => {
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
Create
</ButtonSmall>
</Button>
</div>
))}
</div>
@@ -746,12 +746,12 @@ export const RemoteInstancesPage: React.FC = () => {
autoFocus
/>
<div className="flex items-center justify-end gap-2">
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
Cancel
</ButtonSmall>
<ButtonSmall type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
Create
</ButtonSmall>
</Button>
</div>
</form>
</DialogContent>
@@ -782,7 +782,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<ButtonSmall
<Button
type="button"
variant={canDisconnect ? 'outline' : 'default'}
size="xs"
@@ -792,8 +792,8 @@ export const RemoteInstancesPage: React.FC = () => {
>
{canDisconnect ? <RiStopLine className="h-3.5 w-3.5" /> : <RiPlug2Line className="h-3.5 w-3.5" />}
{primaryButtonLabel}
</ButtonSmall>
<ButtonSmall
</Button>
<Button
type="button"
variant="outline"
size="xs"
@@ -803,8 +803,8 @@ export const RemoteInstancesPage: React.FC = () => {
>
<RiRefreshLine className={`h-3.5 w-3.5 ${isConnecting || (isReconnecting && !reconnectAppearsStuck) ? 'animate-spin' : ''}`} />
{retryButtonLabel}
</ButtonSmall>
<ButtonSmall
</Button>
<Button
type="button"
variant="outline"
size="xs"
@@ -815,8 +815,8 @@ export const RemoteInstancesPage: React.FC = () => {
>
<RiTerminalWindowLine className="h-3.5 w-3.5" />
Logs
</ButtonSmall>
<ButtonSmall
</Button>
<Button
type="button"
variant="outline"
size="xs"
@@ -838,7 +838,7 @@ export const RemoteInstancesPage: React.FC = () => {
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
Remove
</ButtonSmall>
</Button>
</div>
{status?.localUrl ? (
<div className="flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
@@ -1114,7 +1114,7 @@ export const RemoteInstancesPage: React.FC = () => {
}}
emptyLabel="Auto"
/>
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -1131,7 +1131,7 @@ export const RemoteInstancesPage: React.FC = () => {
}
>
<RiShuffleLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
</section>
@@ -1254,7 +1254,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
<div className="flex items-center gap-2">
<Switch checked={forward.enabled} onCheckedChange={(checked) => updateForward((item) => ({ ...item, enabled: checked }))} aria-label="Enable forward" />
<ButtonSmall
<Button
type="button"
variant="ghost"
size="xs"
@@ -1267,7 +1267,7 @@ export const RemoteInstancesPage: React.FC = () => {
}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
<CollapsibleContent className="pt-2">
@@ -1416,7 +1416,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
{canOpenLocalEndpoint ? (
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -1431,7 +1431,7 @@ export const RemoteInstancesPage: React.FC = () => {
>
<RiExternalLinkLine className="h-3.5 w-3.5" />
Open local
</ButtonSmall>
</Button>
) : null}
</div>
</div>
@@ -1440,7 +1440,7 @@ export const RemoteInstancesPage: React.FC = () => {
);
})}
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -1459,7 +1459,7 @@ export const RemoteInstancesPage: React.FC = () => {
>
<RiAddLine className="h-3.5 w-3.5" />
Add forward
</ButtonSmall>
</Button>
</section>
</div>
@@ -1486,7 +1486,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
<div className="typography-micro text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -1494,7 +1494,7 @@ export const RemoteInstancesPage: React.FC = () => {
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
Import
</ButtonSmall>
</Button>
</div>
))}
</div>
@@ -1504,12 +1504,12 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="sticky bottom-0 z-10 -mx-3 sm:-mx-6 bg-[var(--surface-background)] border-t border-[var(--interactive-border)] px-3 sm:px-6 py-3">
<div className="flex items-center gap-2">
<ButtonSmall type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
Save changes
</ButtonSmall>
</Button>
{status?.localUrl ? (
<>
<ButtonSmall
<Button
type="button"
variant="outline"
size="xs"
@@ -1524,8 +1524,8 @@ export const RemoteInstancesPage: React.FC = () => {
>
<RiFileCopyLine className="h-3.5 w-3.5" />
Copy local URL
</ButtonSmall>
<ButtonSmall
</Button>
<Button
type="button"
variant="outline"
size="xs"
@@ -1536,7 +1536,7 @@ export const RemoteInstancesPage: React.FC = () => {
>
<RiExternalLinkLine className="h-3.5 w-3.5" />
Open
</ButtonSmall>
</Button>
</>
) : null}
{error ? <div className="ml-auto typography-meta text-[var(--status-error)]">{error}</div> : null}
@@ -1552,14 +1552,14 @@ export const RemoteInstancesPage: React.FC = () => {
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-end gap-2">
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={handleCopyAllLogs} disabled={logDialogLoading || !logLinesText.trim()}>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleCopyAllLogs} disabled={logDialogLoading || !logLinesText.trim()}>
<RiFileCopyLine className="h-3.5 w-3.5" />
Copy all
</ButtonSmall>
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void handleClearLogs()} disabled={logDialogLoading}>
</Button>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void handleClearLogs()} disabled={logDialogLoading}>
<RiDeleteBinLine className="h-3.5 w-3.5" />
Clear
</ButtonSmall>
</Button>
</div>
{logDialogLoading ? (
<div className="typography-meta text-muted-foreground">Loading logs...</div>
@@ -1602,12 +1602,12 @@ export const RemoteInstancesPage: React.FC = () => {
autoFocus
/>
<div className="flex items-center justify-end gap-2">
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
Cancel
</ButtonSmall>
<ButtonSmall type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
Create
</ButtonSmall>
</Button>
</div>
</form>
</DialogContent>
@@ -1,5 +1,5 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
@@ -20,7 +20,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
import {
SKILL_LOCATION_OPTIONS,
@@ -459,9 +458,9 @@ const SkillsInstalledPage: React.FC = () => {
<h3 className="typography-ui-header font-medium text-foreground">
Supporting Files
</h3>
<ButtonSmall variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
<RiAddLine className="h-3.5 w-3.5" /> Add File
</ButtonSmall>
</Button>
</div>
<section className="px-2 pb-2 pt-0">
@@ -491,7 +490,7 @@ const SkillsInstalledPage: React.FC = () => {
pending
</span>
)}
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-5 w-5 px-0 flex-shrink-0 text-muted-foreground hover:text-[var(--status-error)] opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
@@ -500,7 +499,7 @@ const SkillsInstalledPage: React.FC = () => {
}}
>
<RiDeleteBinLine className="h-3 w-3" />
</ButtonSmall>
</Button>
</div>
))}
</div>
@@ -511,14 +510,14 @@ const SkillsInstalledPage: React.FC = () => {
{/* Save action */}
<div className="px-2 py-1">
<ButtonSmall
<Button
onClick={handleSave}
disabled={isSaving || !hasSkillChanges}
size="xs"
className="!font-normal"
>
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
</ButtonSmall>
</Button>
</div>
</div>
@@ -540,16 +539,17 @@ const SkillsInstalledPage: React.FC = () => {
</DialogDescription>
</DialogHeader>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteFilePath(null)}
disabled={isDeletingFile}
>
Cancel
</ButtonLarge>
<ButtonLarge onClick={handleConfirmDeleteFile} disabled={isDeletingFile} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
</Button>
<Button size="sm" variant="destructive" onClick={handleConfirmDeleteFile} disabled={isDeletingFile}>
Delete
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -603,7 +603,8 @@ const SkillsInstalledPage: React.FC = () => {
</div>
)}
<DialogFooter className="mt-4">
<ButtonLarge
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsFileDialogOpen(false);
@@ -611,10 +612,10 @@ const SkillsInstalledPage: React.FC = () => {
}}
>
Cancel
</ButtonLarge>
<ButtonLarge onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
</Button>
<Button size="sm" onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
{editingFilePath ? 'Save Changes' : 'Create File'}
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,6 +1,5 @@
import React, { useMemo } from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
@@ -207,13 +206,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {skills.length}</span>
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</div>
</div>
@@ -347,17 +346,18 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
variant="ghost"
onClick={() => setDeleteDialogSkill(null)}
disabled={isDeletePending}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</ButtonLarge>
<ButtonLarge onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
</Button>
<Button size="sm" onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
Delete
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -383,16 +383,17 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}}
/>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
variant="ghost"
onClick={() => setRenameDialogSkill(null)}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
>
Cancel
</ButtonLarge>
<ButtonLarge onClick={handleRenameSkill}>
</Button>
<Button size="sm" onClick={handleRenameSkill}>
Rename
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -461,12 +462,12 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -311,24 +311,26 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div>
<DialogFooter>
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</ButtonLarge>
<ButtonLarge
</Button>
<Button
size="sm"
className="gap-2"
variant="ghost"
onClick={() => void handleScan()}
disabled={isScanning || !source.trim()}
className="gap-2"
>
<RiGitRepositoryLine className="h-4 w-4" />
{isScanning ? 'Scanning...' : 'Scan'}
</ButtonLarge>
<ButtonLarge
</Button>
<Button
size="sm"
onClick={() => void handleAdd()}
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
>
Add catalog
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -8,8 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -73,8 +72,8 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
<div className="flex items-center gap-2">
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</ButtonSmall>
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</ButtonSmall>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</Button>
</div>
</div>
@@ -113,15 +112,16 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
</div>
<DialogFooter>
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</ButtonLarge>
<ButtonLarge
</Button>
<Button
size="sm"
onClick={() => onConfirm(decisions)}
disabled={!canConfirm}
>
Continue
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -10,7 +10,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Checkbox } from '@/components/ui/checkbox';
@@ -487,15 +486,16 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div>
<DialogFooter className="flex-shrink-0">
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</ButtonLarge>
<ButtonLarge
</Button>
<Button
size="sm"
disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
onClick={() => void doInstall({})}
>
{isInstalling ? 'Installing…' : 'Install selected'}
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -231,13 +231,15 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
</div>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
variant="ghost"
onClick={() => onOpenChange(false)}
>
Cancel
</ButtonLarge>
<ButtonLarge
</Button>
<Button
size="sm"
disabled={isInstalling || !item.installable || (scope === 'project' && !directoryOverride)}
onClick={() =>
void doInstall({
@@ -251,7 +253,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
}
>
{isInstalling ? 'Installing...' : 'Install'}
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,6 +1,6 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
@@ -12,7 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import {
Select,
SelectContent,
@@ -195,7 +194,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</SelectContent>
</Select>
<ButtonSmall
<Button
variant="outline"
size="xs"
className="!font-normal h-6 w-6 px-0"
@@ -210,10 +209,10 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
title="Refresh"
>
<RiRefreshLine className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
</ButtonSmall>
</Button>
{isCustomSource && (
<ButtonSmall
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
@@ -222,16 +221,16 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
title="Remove Catalog"
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</ButtonSmall>
</Button>
)}
<ButtonSmall
<Button
size="xs"
className="!font-normal gap-1"
onClick={() => setAddCatalogOpen(true)}
>
<RiAddLine className="h-3.5 w-3.5" /> Add Catalog
</ButtonSmall>
</Button>
</div>
<div className="py-1.5">
@@ -328,7 +327,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
) : null}
</div>
<ButtonSmall
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
@@ -339,7 +338,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
}}
>
Install
</ButtonSmall>
</Button>
</div>
</div>
);
@@ -350,7 +349,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2 px-2">
<ButtonSmall
<Button
variant="outline"
size="xs"
className="!font-normal"
@@ -358,7 +357,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
disabled={isLoadingMore}
>
{isLoadingMore ? 'Loading...' : 'Load More Skills'}
</ButtonSmall>
</Button>
</div>
)}
</div>
@@ -381,16 +380,17 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
</DialogHeader>
<DialogFooter>
<ButtonLarge
<Button
size="sm"
variant="ghost"
onClick={() => setIsRemoveCatalogDialogOpen(false)}
disabled={isRemovingCatalog}
>
Cancel
</ButtonLarge>
<ButtonLarge className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
</Button>
<Button size="sm" variant="destructive" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
Remove Catalog
</ButtonLarge>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,7 +1,7 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ButtonSmall } from '@/components/ui/button-small';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -111,7 +111,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
<SelectItem value="300000">5m</SelectItem>
</SelectContent>
</Select>
<ButtonSmall
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 text-muted-foreground"
onClick={() => fetchAllQuotas()}
@@ -120,7 +120,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
disabled={isLoading}
>
<RiRefreshLine className={cn('h-3.5 w-3.5', isLoading && 'animate-spin')} />
</ButtonSmall>
</Button>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
@@ -148,8 +148,8 @@ const SessionFolderItemBase = <TSessionNode,>({
<div
ref={droppableRef}
className={cn(
'group/folder flex items-center justify-between gap-1.5 py-1 min-w-0 rounded-sm',
'hover:bg-interactive-hover/50 cursor-pointer',
'group/folder relative flex items-center justify-between gap-1.5 py-1 min-w-0 rounded-md',
'cursor-pointer',
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
)}
onClick={renaming ? undefined : onToggle}
@@ -167,7 +167,12 @@ const SessionFolderItemBase = <TSessionNode,>({
}
aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`}
>
<div className="min-w-0 flex items-center gap-1.5 pl-1.5 flex-1">
<div className={cn(
'min-w-0 flex items-center gap-1.5 pl-1.5 flex-1 transition-[padding]',
archivedBucket
? (mobileVariant ? 'pr-7' : 'group-hover/folder:pr-7 group-focus-within/folder:pr-7')
: '',
)}>
<FolderIcon className={cn('h-3.5 w-3.5 flex-shrink-0', isDropTarget ? 'text-primary' : 'text-muted-foreground')} />
{renaming ? (
@@ -243,15 +248,16 @@ const SessionFolderItemBase = <TSessionNode,>({
</div>
{/* Action buttons */}
{!renaming && !hideActions ? (
{!renaming && (!hideActions || archivedBucket) ? (
<div className="flex items-center gap-0.5 px-0.5">
<div
className={cn(
'flex items-center gap-0.5 transition-opacity',
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
archivedBucket && 'absolute right-0.5 top-1/2 z-10 -translate-y-1/2 px-0',
)}
>
{onNewSession ? (
{!archivedBucket && onNewSession ? (
<button
type="button"
onClick={(event) => {
@@ -266,7 +272,7 @@ const SessionFolderItemBase = <TSessionNode,>({
</button>
) : null}
{/* Only allow sub-folders at depth 0 (one level deep max) */}
{onNewSubFolder && depth === 0 ? (
{!archivedBucket && onNewSubFolder && depth === 0 ? (
<button
type="button"
onClick={(event) => {
@@ -280,17 +286,19 @@ const SessionFolderItemBase = <TSessionNode,>({
<RiFolderAddLine className="h-3.5 w-3.5" />
</button>
) : null}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleStartRename();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Rename folder ${folder.name}`}
>
<RiPencilAiLine className="h-3.5 w-3.5" />
</button>
{!archivedBucket ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleStartRename();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Rename folder ${folder.name}`}
>
<RiPencilAiLine className="h-3.5 w-3.5" />
</button>
) : null}
<button
type="button"
onClick={(event) => {
@@ -298,7 +306,7 @@ const SessionFolderItemBase = <TSessionNode,>({
onDelete();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Delete folder ${folder.name}`}
aria-label={archivedBucket ? `Delete archived sessions in folder ${folder.name}` : `Delete folder ${folder.name}`}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
@@ -1,6 +1,8 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { RiLayoutLeftLine } from '@remixicon/react';
import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -9,10 +11,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import type { GitHubPullRequestStatus } from '@/lib/api/types';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { useGitStore } from '@/stores/useGitStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -35,10 +35,14 @@ import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { SessionGroupSection } from './sidebar/SessionGroupSection';
import { SidebarHeader } from './sidebar/SidebarHeader';
import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
import type { SortableDragHandleProps } from './sidebar/sortableItems';
import {
FolderDeleteConfirmDialog,
SessionDeleteConfirmDialog,
@@ -46,6 +50,13 @@ import {
type DeleteSessionConfirmState,
} from './sidebar/ConfirmDialogs';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
addActiveNowSession,
deriveActiveNowSessions,
persistActiveNowEntries,
pruneActiveNowEntries,
readActiveNowEntries,
} from './sidebar/activitySections';
import {
compareSessionsByPinnedAndTime,
formatProjectLabel,
@@ -130,7 +141,6 @@ interface SessionSidebarProps {
onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean;
hideDirectoryControls?: boolean;
hideProjectSelector?: boolean;
showOnlyMainWorkspace?: boolean;
}
@@ -139,7 +149,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
onSessionSelected,
allowReselect = false,
hideDirectoryControls = false,
hideProjectSelector = true,
showOnlyMainWorkspace = false,
}) => {
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
@@ -148,13 +157,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const sessionSearchInputRef = React.useRef<HTMLInputElement | null>(null);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editTitle, setEditTitle] = React.useState('');
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
const [editProjectTitle, setEditProjectTitle] = React.useState('');
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
const [directoryStatus, setDirectoryStatus] = React.useState<Map<string, 'unknown' | 'exists' | 'missing'>>(
() => new Map(),
);
const safeStorage = React.useMemo(() => getSafeStorage(), []);
const [activeNowEntries, setActiveNowEntries] = React.useState(() => readActiveNowEntries(safeStorage));
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
@@ -162,7 +171,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState<string | null>(null);
const [renamingFolderId, setRenamingFolderId] = React.useState<string | null>(null);
const [renameFolderDraft, setRenameFolderDraft] = React.useState('');
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
@@ -228,8 +237,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
});
const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
const ignoreIntersectionUntil = React.useRef<number>(0);
@@ -243,17 +250,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const addProject = useProjectsStore((state) => state.addProject);
const removeProject = useProjectsStore((state) => state.removeProject);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const renameProject = useProjectsStore((state) => state.renameProject);
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
const reorderProjects = useProjectsStore((state) => state.reorderProjects);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog);
const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
const deviceInfo = useDeviceInfo();
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
const debouncedSessionSearchQuery = useDebouncedValue(sessionSearchQuery, 120);
const normalizedSessionSearchQuery = React.useMemo(
@@ -307,8 +318,89 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isMacPlatform = React.useMemo(() => {
if (typeof navigator === 'undefined') {
return false;
}
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
}, []);
const showDesktopSidebarChrome = !mobileVariant && !isVSCode;
const desktopSidebarTopPaddingClass = isDesktopShellRuntime && isMacPlatform && !isDesktopWindowFullscreen ? 'pl-[5.5rem]' : 'pl-3';
const desktopSidebarToggleButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md typography-ui-label font-medium text-foreground transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50';
React.useEffect(() => {
if (!isDesktopShellRuntime || !isMacPlatform) {
setIsDesktopWindowFullscreen(false);
return;
}
let disposed = false;
let unlistenResize: (() => void) | null = null;
const syncFullscreenState = async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
const fullscreen = await currentWindow.isFullscreen();
if (!disposed) {
setIsDesktopWindowFullscreen(fullscreen);
}
} catch {
if (!disposed) {
setIsDesktopWindowFullscreen(false);
}
}
};
const attach = async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
unlistenResize = await currentWindow.onResized(() => {
void syncFullscreenState();
});
} catch {
// Ignore listener setup failures; fallback state remains false.
}
};
void syncFullscreenState();
void attach();
return () => {
disposed = true;
if (unlistenResize) {
unlistenResize();
}
};
}, [isDesktopShellRuntime, isMacPlatform]);
const handleDesktopSidebarDragStart = React.useCallback(async (event: React.MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest('.app-region-no-drag')) {
return;
}
if (target.closest('button, a, input, select, textarea')) {
return;
}
if (event.button !== 0) {
return;
}
if (!isDesktopShellRuntime) {
return;
}
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const appWindow = getCurrentWindow();
await appWindow.startDragging();
} catch (error) {
console.error('Failed to start window dragging:', error);
}
}, [isDesktopShellRuntime]);
const {
buildGroupSearchText,
@@ -359,11 +451,93 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
useSessionPrefetch({
currentSessionId,
sortedSessions,
loadMessages,
});
const allKnownSessionsById = React.useMemo(() => {
const next = new Map<string, Session>();
[...sessions, ...archivedSessions].forEach((session) => {
next.set(session.id, session);
});
return next;
}, [sessions, archivedSessions]);
React.useEffect(() => {
const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById);
if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) {
return;
}
setActiveNowEntries(pruned);
persistActiveNowEntries(safeStorage, pruned);
}, [activeNowEntries, allKnownSessionsById, safeStorage]);
const previousStreamingIdsRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
const nextStreamingIds = new Set<string>();
sessionStatus?.forEach((status, sessionId) => {
if (status?.type === 'busy' || status?.type === 'retry') {
nextStreamingIds.add(sessionId);
}
});
const previousStreamingIds = previousStreamingIdsRef.current;
const startedStreamingIds = Array.from(nextStreamingIds).filter((sessionId) => !previousStreamingIds.has(sessionId));
if (startedStreamingIds.length > 0) {
setActiveNowEntries((prev) => {
const next = startedStreamingIds.reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev);
if (next === prev) {
return prev;
}
persistActiveNowEntries(safeStorage, next);
return next;
});
}
previousStreamingIdsRef.current = nextStreamingIds;
}, [sessionStatus, safeStorage]);
React.useEffect(() => {
const busyIds: string[] = [];
sessionStatus?.forEach((status, sessionId) => {
if (status?.type === 'busy' || status?.type === 'retry') {
busyIds.push(sessionId);
}
});
if (busyIds.length === 0) {
return;
}
setActiveNowEntries((prev) => {
const known = new Set(prev.map((entry) => entry.sessionId));
let next = prev;
let changed = false;
busyIds.forEach((sessionId) => {
if (known.has(sessionId)) {
return;
}
const session = allKnownSessionsById.get(sessionId);
if (!session || session.time?.archived) {
return;
}
const isSubtask = Boolean((session as Session & { parentID?: string | null }).parentID);
if (isSubtask) {
return;
}
next = addActiveNowSession(next, sessionId);
known.add(sessionId);
changed = true;
});
if (!changed) {
return prev;
}
persistActiveNowEntries(safeStorage, next);
return next;
});
}, [sessionStatus, allKnownSessionsById, safeStorage]);
const childrenMap = React.useMemo(() => {
const map = new Map<string, Session[]>();
@@ -394,17 +568,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</div>
);
const handleSaveProjectEdit = React.useCallback(() => {
if (editingProjectId && editProjectTitle.trim()) {
renameProject(editingProjectId, editProjectTitle.trim());
setEditingProjectId(null);
setEditProjectTitle('');
}
}, [editingProjectId, editProjectTitle, renameProject]);
const editingProject = React.useMemo(
() => projects.find((project) => project.id === editingProjectDialogId) ?? null,
[projects, editingProjectDialogId],
);
const handleCancelProjectEdit = React.useCallback(() => {
setEditingProjectId(null);
setEditProjectTitle('');
const handleSaveProjectEdit = React.useCallback((data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
if (!editingProjectDialogId) {
return;
}
updateProjectMeta(editingProjectDialogId, data);
setEditingProjectDialogId(null);
}, [editingProjectDialogId, updateProjectMeta]);
const openNewWorktreeDialog = React.useCallback(() => {
setNewWorktreeDialogOpen(true);
}, []);
const deleteSession = useSessionStore((state) => state.deleteSession);
@@ -569,6 +747,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
path: string;
label?: string;
normalizedPath: string;
icon?: string;
color?: string;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
iconBackground?: string;
}>;
}, [projects]);
@@ -627,7 +809,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
searchMatchCount,
} = useSessionSidebarSections({
normalizedProjects,
activeProjectId,
getSessionsForProject,
getArchivedSessionsForProject,
availableWorktreesByProject,
@@ -671,7 +852,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const stableActiveProjectIsRepo = activeProjectForHeader && projectRepoStatus.has(activeProjectForHeader.id)
? activeProjectIsRepo
: lastRepoStatusRef.current;
const reserveHeaderActionsSpace = Boolean(activeProjectForHeader);
const reserveHeaderActionsSpace = true;
const useMobileNotesPanel = mobileVariant || deviceInfo.isMobile;
React.useEffect(() => {
@@ -687,7 +868,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setActiveSessionByProject,
currentSessionId,
handleSessionSelect,
isVSCode,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
@@ -698,30 +878,141 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
const hasInitializedArchivedCollapseRef = React.useRef(false);
const handleStartInlineProjectRename = React.useCallback(() => {
if (!activeProjectForHeader) {
React.useEffect(() => {
if (hasInitializedArchivedCollapseRef.current || projectSections.length === 0) {
return;
}
setProjectRenameDraft(formatProjectLabel(
activeProjectForHeader.label?.trim()
|| formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory)
|| activeProjectForHeader.normalizedPath,
));
setIsProjectRenameInline(true);
}, [activeProjectForHeader, homeDirectory]);
const archivedGroupKeys = projectSections.flatMap((section) =>
section.groups
.filter((group) => group.isArchivedBucket)
.map((group) => `${section.project.id}:${group.id}`),
);
if (archivedGroupKeys.length > 0) {
setCollapsedGroups((prev) => new Set([...prev, ...archivedGroupKeys]));
}
hasInitializedArchivedCollapseRef.current = true;
}, [projectSections]);
const handleSaveInlineProjectRename = React.useCallback(() => {
if (!activeProjectForHeader) {
return;
const sessionSidebarMetaById = React.useMemo(() => {
const meta = new Map<string, {
node: SessionNode;
projectId: string | null;
groupDirectory: string | null;
secondaryMeta: {
projectLabel?: string | null;
branchLabel?: string | null;
} | null;
}>();
projectSections.forEach((section) => {
const projectLabel = formatProjectLabel(
section.project.label?.trim()
|| formatDirectoryName(section.project.normalizedPath, homeDirectory)
|| section.project.normalizedPath,
);
section.groups.forEach((group) => {
const secondaryMeta = group.branch && group.branch !== projectLabel
? { projectLabel, branchLabel: group.branch }
: { projectLabel, branchLabel: null };
const visit = (nodes: SessionNode[]) => {
nodes.forEach((node) => {
meta.set(node.session.id, {
node,
projectId: section.project.id,
groupDirectory: group.directory,
secondaryMeta,
});
if (node.children.length > 0) {
visit(node.children);
}
});
};
visit(group.sessions);
});
});
return meta;
}, [projectSections, homeDirectory]);
const activeNowSessions = React.useMemo(
() => deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))),
[activeNowEntries, sessions],
);
useSessionPrefetch({
currentSessionId,
sortedSessions,
recentSessionIds: activeNowSessions.map((session) => session.id),
loadMessages,
});
const activitySections = React.useMemo(() => {
const toItem = (session: Session) => {
const existing = sessionSidebarMetaById.get(session.id);
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
return {
node: existing?.node ?? { session, children: [], worktree: null },
projectId: existing?.projectId ?? null,
groupDirectory: existing?.groupDirectory ?? sessionDirectory,
secondaryMeta: existing?.secondaryMeta ?? null,
};
};
return [
{ key: 'active-now' as const, title: 'recent', items: activeNowSessions.map(toItem) },
];
}, [activeNowSessions, sessionSidebarMetaById]);
const activitySessionIds = React.useMemo(() => {
const next = new Set<string>();
activitySections.forEach((section) => {
section.items.forEach((item) => {
next.add(item.node.session.id);
});
});
return next;
}, [activitySections]);
const filteredProjectSections = React.useMemo(() => {
if (hasSessionSearchQuery || activitySessionIds.size === 0) {
return projectSections;
}
const trimmed = projectRenameDraft.trim();
if (!trimmed) {
return;
const filterNodes = (nodes: SessionNode[]): SessionNode[] => {
return nodes.flatMap((node) => {
if (activitySessionIds.has(node.session.id)) {
return [];
}
return [{
...node,
children: filterNodes(node.children),
}];
});
};
return projectSections.map((section) => ({
...section,
groups: section.groups.map((group) => ({
...group,
sessions: filterNodes(group.sessions),
})),
}));
}, [hasSessionSearchQuery, activitySessionIds, projectSections]);
const filteredSectionsForRender = React.useMemo(() => {
if (hasSessionSearchQuery || activitySessionIds.size === 0) {
return sectionsForRender;
}
renameProject(activeProjectForHeader.id, trimmed);
setIsProjectRenameInline(false);
}, [activeProjectForHeader, projectRenameDraft, renameProject]);
const sectionsByProjectId = new Map(filteredProjectSections.map((section) => [section.project.id, section]));
return sectionsForRender
.map((section) => sectionsByProjectId.get(section.project.id) ?? section)
.filter(Boolean);
}, [hasSessionSearchQuery, activitySessionIds, filteredProjectSections, sectionsForRender]);
const desktopHeaderActionButtonClass =
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
@@ -729,14 +1020,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
const headerActionIconClass = 'h-4.5 w-4.5';
const addProjectButtonClass = cn(
'inline-flex cursor-pointer items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed',
mobileVariant
? 'h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50'
: 'h-8 w-8 text-foreground hover:bg-interactive-hover',
!isDesktopShellRuntime && 'bg-transparent hover:bg-sidebar/40',
);
const stuckProjectHeaders = useStickyProjectHeaders({
isDesktopShellRuntime,
projectSections,
@@ -750,6 +1033,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket = false,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext: 'project' | 'recent' = 'project',
): React.ReactNode => (
<SessionNodeItem
node={node}
@@ -782,8 +1067,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
copiedSessionId={copiedSessionId}
handleCopyShareUrl={handleCopyShareUrl}
handleUnshareSession={handleUnshareSession}
openMenuSessionId={openMenuSessionId}
setOpenMenuSessionId={setOpenMenuSessionId}
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
renamingFolderId={renamingFolderId}
getFoldersForScope={getFoldersForScope}
getSessionFolderId={getSessionFolderId}
@@ -794,6 +1079,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
handleDeleteSession={handleDeleteSession}
mobileVariant={mobileVariant}
renderSessionNode={renderSessionNode}
secondaryMeta={secondaryMeta}
renderContext={renderContext}
/>
),
[
@@ -822,8 +1109,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
copiedSessionId,
handleCopyShareUrl,
handleUnshareSession,
openMenuSessionId,
setOpenMenuSessionId,
openSidebarMenuKey,
setOpenSidebarMenuKey,
renamingFolderId,
getFoldersForScope,
getSessionFolderId,
@@ -898,7 +1185,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, [prStatusEntries]);
const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => (
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null) => (
<SessionGroupSection
group={group}
groupKey={groupKey}
@@ -937,6 +1224,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
pinnedSessionIds={pinnedSessionIds}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup}
dragHandleProps={dragHandleProps}
/>
),
[
@@ -972,44 +1260,62 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
],
);
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectId);
const topContent = !hasSessionSearchQuery ? (
<SidebarActivitySections
sections={activitySections}
renderSessionNode={renderSessionNode}
/>
) : null;
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
const handleSidebarNewSession = React.useCallback(() => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft();
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
return (
<div
ref={sessionSearchContainerRef}
className={cn(
'flex h-full flex-col text-foreground overflow-x-hidden',
'relative flex h-full flex-col text-foreground overflow-x-hidden',
mobileVariant ? '' : 'bg-transparent',
)}
>
{showDesktopSidebarChrome ? (
<div
onMouseDown={handleDesktopSidebarDragStart}
className={cn(
'app-region-drag flex h-[var(--oc-header-height,56px)] flex-shrink-0 items-center pr-3',
desktopSidebarTopPaddingClass,
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleSidebar}
className={desktopSidebarToggleButtonClass}
aria-label="Close sessions"
>
<RiLayoutLeftLine className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Close sessions</p>
</TooltipContent>
</Tooltip>
</div>
) : null}
<SidebarHeader
hideDirectoryControls={hideDirectoryControls}
hideProjectSelector={hideProjectSelector}
activeProjectForHeader={activeProjectForHeader}
homeDirectory={homeDirectory}
normalizedProjects={normalizedProjects}
activeProjectId={activeProjectId}
setActiveProjectIdOnly={setActiveProjectIdOnly}
isProjectRenameInline={isProjectRenameInline}
setIsProjectRenameInline={setIsProjectRenameInline}
handleStartInlineProjectRename={handleStartInlineProjectRename}
handleSaveInlineProjectRename={handleSaveInlineProjectRename}
projectRenameDraft={projectRenameDraft}
setProjectRenameDraft={setProjectRenameDraft}
removeProject={removeProject}
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
addProjectButtonClass={addProjectButtonClass}
handleNewSession={handleSidebarNewSession}
headerActionIconClass={headerActionIconClass}
reserveHeaderActionsSpace={reserveHeaderActionsSpace}
stableActiveProjectIsRepo={stableActiveProjectIsRepo}
useMobileNotesPanel={useMobileNotesPanel}
projectNotesPanelOpen={projectNotesPanelOpen}
setProjectNotesPanelOpen={setProjectNotesPanelOpen}
activeProjectRefForHeader={activeProjectRefForHeader}
openMultiRunLauncher={openMultiRunLauncher}
headerActionButtonClass={headerActionButtonClass}
setNewWorktreeDialogOpen={setNewWorktreeDialogOpen}
setActiveMainTab={setActiveMainTab}
isSessionSearchOpen={isSessionSearchOpen}
setIsSessionSearchOpen={setIsSessionSearchOpen}
sessionSearchInputRef={sessionSearchInputRef}
@@ -1020,8 +1326,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
/>
<SidebarProjectsList
sectionsForRender={sectionsForRender}
projectSections={projectSections}
topContent={topContent}
sectionsForRender={filteredSectionsForRender}
projectSections={filteredProjectSections}
activeProjectId={activeProjectId}
showOnlyMainWorkspace={showOnlyMainWorkspace}
hasSessionSearchQuery={hasSessionSearchQuery}
@@ -1042,22 +1349,43 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraft}
createWorktreeSession={createWorktreeSession}
openNewWorktreeDialog={openNewWorktreeDialog}
openMultiRunLauncher={openMultiRunLauncher}
setEditingProjectId={setEditingProjectId}
setEditProjectTitle={setEditProjectTitle}
editingProjectId={editingProjectId}
editProjectTitle={editProjectTitle}
handleSaveProjectEdit={handleSaveProjectEdit}
handleCancelProjectEdit={handleCancelProjectEdit}
openProjectEditDialog={setEditingProjectDialogId}
removeProject={removeProject}
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
settingsAutoCreateWorktree={settingsAutoCreateWorktree}
reorderProjects={reorderProjects}
getOrderedGroups={getOrderedGroups}
setGroupOrderByProject={setGroupOrderByProject}
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
isInlineEditing={isInlineEditing}
/>
<SidebarFooter
onOpenSettings={() => setSettingsDialogOpen(true)}
onOpenShortcuts={toggleHelpDialog}
onOpenAbout={() => setAboutDialogOpen(true)}
/>
{editingProject ? (
<ProjectEditDialog
open={Boolean(editingProject)}
onOpenChange={(open) => {
if (!open) {
setEditingProjectDialogId(null);
}
}}
projectId={editingProject.id}
projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)}
projectPath={editingProject.path}
initialIcon={editingProject.icon}
initialColor={editingProject.color}
initialIconBackground={editingProject.iconBackground}
onSave={handleSaveProjectEdit}
/>
) : null}
<NewWorktreeDialog
open={newWorktreeDialogOpen}
onOpenChange={setNewWorktreeDialogOpen}
@@ -3,24 +3,29 @@
## Refactor result
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
- Sidebar behavior stays intact: global+archived session grouping, folder operations, delete/archive semantics, project/worktree rendering, and search.
- Recent migration gaps were fixed (persistence + repo-status hooks fully wired).
- Sidebar is now a single multi-project tree: `recent` top section, then projects, then worktrees/archived groups, then sessions.
- `NavRail` is no longer part of sidebar/navigation flow.
- Project headers now own root sessions directly; there is no separate rendered `project root` subgroup.
- Active/hover row styling is text-first; selected sessions use primary text instead of background fills.
- Archived groups are collapsed by default and support bulk deletion at group/folder level.
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
- New extractions in latest pass reduced local effect/callback bulk further:
- project session list builders
- folder cleanup sync
- sticky project header observer
- Baseline checks pass after refactor: `type-check`, `lint`, `build`.
## File summaries
### Components
- `SidebarHeader.tsx`: Top header UI (project selector/rename, search, add/open actions, notes/worktree entry points).
- `SidebarProjectsList.tsx`: Main scrollable list renderer for project sections/groups, empty states, and project-level interactions.
- `SessionGroupSection.tsx`: Renders a single group (root sessions + folders), collapse/expand, and group-level controls.
- `SessionNodeItem.tsx`: Renders one session row/tree node with metadata, menu actions, inline rename, and nested children.
- `SidebarHeader.tsx`: Top header UI for add-project, session search, and display mode.
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only.
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
- `SidebarProjectsList.tsx`: Main scrollable tree renderer for projects, root sessions, worktrees/groups, and empty/search states.
- `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, and group-level controls.
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children.
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering with drag handles/overlays.
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering plus project-row action affordances.
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
### Hooks
@@ -32,7 +37,7 @@
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
- `hooks/useGroupOrdering.ts`: Applies persisted/custom group order with stable fallback ordering.
- `hooks/useGroupOrdering.ts`: Applies persisted/custom group order with stable fallback ordering; archived groups are reorderable.
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior.
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
@@ -43,4 +48,5 @@
### Types and utilities
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels).
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting).
@@ -15,9 +15,11 @@ import { sessionEvents } from '@/lib/sessionEvents';
import type { MainTab } from '@/stores/useUIStore';
import { SessionFolderItem } from '../SessionFolderItem';
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
import type { SortableDragHandleProps } from './sortableItems';
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
type DeleteFolderConfirm = {
scopeKey: string;
@@ -45,7 +47,7 @@ type Props = {
deleteFolder: (scopeKey: string, folderId: string) => void;
showDeletionDialog: boolean;
setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null) => React.ReactNode;
currentSessionDirectory: string | null;
projectRepoStatus: Map<string, boolean | null>;
lastRepoStatus: boolean;
@@ -87,6 +89,7 @@ type Props = {
} | null;
}>;
onToggleCollapsedGroup: (groupKey: string) => void;
dragHandleProps?: SortableDragHandleProps | null;
};
export function SessionGroupSection(props: Props): React.ReactNode {
@@ -109,7 +112,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
showDeletionDialog,
setDeleteFolderConfirm,
renderSessionNode,
currentSessionDirectory,
projectRepoStatus,
lastRepoStatus,
toggleGroupSessionLimit,
@@ -128,9 +130,12 @@ export function SessionGroupSection(props: Props): React.ReactNode {
pinnedSessionIds,
prVisualStateByDirectoryBranch,
onToggleCollapsedGroup,
dragHandleProps,
} = props;
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
const displayMode = useSessionDisplayStore((state) => state.displayMode);
const isMinimalMode = displayMode === 'minimal';
const isExpanded = expandedSessionGroups.has(groupKey);
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
const maxVisible = hideDirectoryControls ? 10 : 5;
@@ -204,24 +209,17 @@ export function SessionGroupSection(props: Props): React.ReactNode {
};
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
const isGitProject = projectId && projectRepoStatus.has(projectId)
? Boolean(projectRepoStatus.get(projectId))
: lastRepoStatus;
const isActiveGroup = Boolean(
normalizedGroupDirectory
&& currentSessionDirectory
&& normalizedGroupDirectory === currentSessionDirectory,
);
const groupDirectoryKey = normalizePath(group.directory ?? null);
const groupBranchKey = group.branch?.trim() ?? null;
const prIndicator = groupDirectoryKey && groupBranchKey
? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null)
: null;
const showInlinePrTitle = Boolean(prIndicator && group.branch);
const showBranchSubtitle = !group.isMain && (isBranchDifferentFromLabel(group.branch, group.label) || Boolean(prIndicator));
const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch);
const prVisualState = prIndicator?.visualState ?? null;
const branchIconColor = prVisualState ? `var(--pr-${prVisualState})` : undefined;
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
? `${prIndicator.checks.success}/${prIndicator.checks.total} checks passed`
: null;
@@ -241,6 +239,33 @@ export function SessionGroupSection(props: Props): React.ReactNode {
: null;
const baseBranchLabel = prIndicator?.base ?? null;
const headBranchLabel = prIndicator?.head ?? null;
const statusLine = (() => {
if (!prIndicator) {
return group.branch && isBranchDifferentFromLabel(group.branch, group.label)
? { label: group.branch, color: null as string | null }
: null;
}
switch (prIndicator.visualState) {
case 'merged':
return { label: 'Merged', color: 'var(--pr-merged)' };
case 'open':
return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success')
? { label: 'Ready to merge', color: 'var(--pr-open)' }
: { label: 'PR open', color: 'var(--pr-open)' };
case 'blocked':
return {
label: prIndicator.mergeableState === 'dirty' ? 'Merge conflicts' : 'Merge blocked',
color: 'var(--pr-blocked)',
};
case 'draft':
return { label: 'Draft PR', color: 'var(--pr-draft)' };
case 'closed':
return { label: 'Closed', color: 'var(--pr-closed)' };
default:
return null;
}
})();
const branchIconColor = statusLine?.color ?? (prVisualState ? `var(--pr-${prVisualState})` : undefined);
const handlePrLinkClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -263,6 +288,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const subFolderItems = directSubFolders.length > 0
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
: undefined;
const collectFolderSessions = (targetFolderId: string): Session[] => {
const directNodes = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId)?.nodes ?? [];
const childFolders = allFoldersForGroup.filter(({ folder: candidate }) => candidate.parentId === targetFolderId);
return [
...collectGroupSessions(directNodes),
...childFolders.flatMap(({ folder: child }) => collectFolderSessions(child.id)),
];
};
const folderSessionsForDelete = group.isArchivedBucket ? collectFolderSessions(folder.id) : [];
return (
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
@@ -277,6 +311,13 @@ export function SessionGroupSection(props: Props): React.ReactNode {
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
}}
onDelete={() => {
if (group.isArchivedBucket) {
sessionEvents.requestDelete({
sessions: folderSessionsForDelete,
mode: 'session',
});
return;
}
if (!folderScopeKey) return;
if (!showDeletionDialog) {
deleteFolder(folderScopeKey, folder.id);
@@ -324,7 +365,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
if (!folderScopeKey) return;
createFolderAndStartRename(folderScopeKey, folder.id);
} : undefined}
hideActions={group.isArchivedBucket === true}
hideActions={false}
archivedBucket={group.isArchivedBucket === true}
/>
)}
@@ -333,6 +374,16 @@ export function SessionGroupSection(props: Props): React.ReactNode {
};
const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0));
const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
const groupHeaderRightPadding = mobileVariant
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
: isMinimalMode
? (hasWorktreeDeleteAction
? 'pr-10 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-10')
: (hasWorktreeDeleteAction
? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-5');
const body = (
<SessionFolderDndScope
@@ -371,13 +422,13 @@ export function SessionGroupSection(props: Props): React.ReactNode {
);
if (hideGroupLabel) {
return <div className="oc-group"><div className="oc-group-body pb-3">{body}</div></div>;
return <div className="oc-group"><div className="oc-group-body pb-3 pl-4">{body}</div></div>;
}
return (
<div className="oc-group">
<div
className={cn('group/gh relative flex items-center justify-between gap-1 py-1 min-w-0 rounded-sm', 'hover:bg-interactive-hover/50 cursor-pointer')}
className={cn('group/gh relative flex items-start justify-between gap-1 py-1 min-w-0 rounded-md', 'cursor-pointer')}
onClick={() => onToggleCollapsedGroup(groupKey)}
role="button"
tabIndex={0}
@@ -388,78 +439,43 @@ export function SessionGroupSection(props: Props): React.ReactNode {
}
}}
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
aria-expanded={!isCollapsed}
>
<div className={cn(
'min-w-0 flex items-center gap-1.5 pl-1.5 transition-[padding]',
mobileVariant
? (!group.isMain && group.worktree ? 'pr-14' : 'pr-7')
: (!group.isMain && group.worktree ? 'group-hover/gh:pr-14 group-focus-within/gh:pr-14' : 'group-hover/gh:pr-7 group-focus-within/gh:pr-7'),
)}>
{group.isArchivedBucket ? (
<RiArchiveLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (!group.isMain || isGitProject) ? (
showInlinePrTitle && prIndicator ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<RiGitBranchLine
className="h-3.5 w-3.5 flex-shrink-0 translate-y-[0.5px] text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
<div className="space-y-1 text-xs">
{(baseBranchLabel || headBranchLabel) ? (
<div className="text-muted-foreground truncate">
{baseBranchLabel && headBranchLabel ? (
<>
<span>{baseBranchLabel}</span>
<RiArrowLeftLongLine className="mx-0.5 inline h-3 w-3 align-[-2px]" />
<span>{headBranchLabel}</span>
</>
) : (
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
)}
</div>
) : null}
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
{(mergeabilityLabel || checksSummary) ? (
<div className="text-muted-foreground truncate">
{mergeabilityLabel ?? ''}
{mergeabilityLabel && checksSummary ? ' • ' : ''}
{checksSummary ?? ''}
{checksTail ? ` (${checksTail})` : ''}
</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
) : (
<RiGitBranchLine
className="h-3.5 w-3.5 flex-shrink-0 translate-y-[0.5px] text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
)
) : null}
<div className="min-w-0 flex flex-col justify-center">
<p className={cn('text-[14px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
<div
ref={dragHandleProps?.setActivatorNodeRef}
className={cn(
'min-w-0 flex items-start gap-1 pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
groupHeaderRightPadding,
)}
{...(dragHandleProps?.listeners ?? {})}
>
<div className="min-w-0 flex flex-col justify-center gap-0.5">
<p className="text-[14px] font-normal truncate text-foreground/92">
{showInlinePrTitle && prIndicator ? (
<>
<span className="inline-flex min-w-0 max-w-full items-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-baseline gap-1">
<span className="inline-flex shrink-0 items-center gap-1 leading-none align-middle">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<RiGitBranchLine
className="h-3.5 w-3.5 shrink-0 group-hover/gh:hidden"
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
<span className="hidden text-muted-foreground group-hover/gh:inline-flex h-3.5 w-3.5 items-center justify-center">
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
</span>
</span>
{prIndicator.url ? (
<button
type="button"
className="inline-flex items-baseline leading-none underline hover:no-underline"
className="inline-flex shrink-0 items-center leading-none"
onMouseDown={(event) => event.stopPropagation()}
onClick={handlePrLinkClick}
>
#{prIndicator.number}
</button>
) : (
<span className="leading-none">#{prIndicator.number}</span>
<span className="inline-flex shrink-0 items-center leading-none">#{prIndicator.number}</span>
)}
</span>
</TooltipTrigger>
@@ -490,42 +506,117 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</div>
</TooltipContent>
</Tooltip>
<span>{` ${group.branch}`}</span>
</>
<span className="ml-1 min-w-0 flex-1 truncate leading-none align-middle">{group.branch}</span>
</span>
) : group.isArchivedBucket ? (
<span className="inline-flex min-w-0 items-center gap-1">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<RiArchiveLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground group-hover/gh:hidden" />
<span className="hidden text-muted-foreground group-hover/gh:inline-flex h-3.5 w-3.5 items-center justify-center">
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
</span>
</span>
<span className="truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
</span>
) : (!group.isMain || group.worktree) ? (
<span className="inline-flex min-w-0 items-center gap-1">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<RiGitBranchLine
className="h-3.5 w-3.5 shrink-0 text-muted-foreground group-hover/gh:hidden"
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
<span className="hidden text-muted-foreground group-hover/gh:inline-flex h-3.5 w-3.5 items-center justify-center">
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
</span>
</span>
<span className="truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
</span>
) : (
renderHighlightedText(group.label, normalizedSessionSearchQuery)
)}
</p>
{!showInlinePrTitle && showBranchSubtitle ? (
<span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight">
{prIndicator ? (
<>
{prIndicator.url ? (
<button
type="button"
className="underline hover:no-underline"
onMouseDown={(event) => event.stopPropagation()}
onClick={handlePrLinkClick}
>
#{prIndicator.number}
</button>
) : (
<span>#{prIndicator.number}</span>
)}
{group.branch ? <span>{` ${group.branch}`}</span> : null}
</>
) : (
group.branch
)}
{showBranchSubtitle && statusLine ? (
<span className="inline-flex min-w-0 items-center gap-1.5 leading-tight">
{group.isArchivedBucket ? (
<RiArchiveLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (!group.isMain || isGitProject) ? (
showInlinePrTitle && prIndicator ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<RiGitBranchLine
className="h-3.5 w-3.5 text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
<div className="space-y-1 text-xs">
{(baseBranchLabel || headBranchLabel) ? (
<div className="text-muted-foreground truncate">
{baseBranchLabel && headBranchLabel ? (
<>
<span>{baseBranchLabel}</span>
<RiArrowLeftLongLine className="mx-0.5 inline h-3 w-3 align-[-2px]" />
<span>{headBranchLabel}</span>
</>
) : (
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
)}
</div>
) : null}
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
{(mergeabilityLabel || checksSummary) ? (
<div className="text-muted-foreground truncate">
{mergeabilityLabel ?? ''}
{mergeabilityLabel && checksSummary ? ' • ' : ''}
{checksSummary ?? ''}
{checksTail ? ` (${checksTail})` : ''}
</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
) : (
<RiGitBranchLine
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
)
) : null}
<span
className={cn('min-w-0 truncate text-[11px] font-medium', !statusLine.color && 'text-muted-foreground/80')}
style={statusLine.color ? { color: statusLine.color } : undefined}
>
{statusLine.label}
</span>
</span>
) : null}
</div>
{isCollapsed ? (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)}
</div>
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
sessionEvents.requestDelete({
sessions: allGroupSessions,
mode: 'session',
});
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Delete archived sessions in ${group.label}`}
>
<RiDeleteBinLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Delete archived sessions</p></TooltipContent>
</Tooltip>
</div>
) : null}
{group.directory && !group.isMain && group.worktree ? (
<div className={cn('absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity', mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip>
@@ -564,17 +655,17 @@ export function SessionGroupSection(props: Props): React.ReactNode {
openNewSessionDraft({ directoryOverride: group.directory });
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`New session in ${group.label}`}
>
<RiAddLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New session</p></TooltipContent>
</Tooltip>
</div>
) : null}
aria-label={`New draft session in ${group.label}`}
>
<RiAddLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New draft session</p></TooltipContent>
</Tooltip>
</div>
) : null}
</div>
{!isCollapsed ? <div className="oc-group-body pb-3">{body}</div> : null}
{!isCollapsed ? <div className="oc-group-body pb-3 pl-4">{body}</div> : null}
</div>
);
}
@@ -22,22 +22,21 @@ import {
RiDeleteBinLine,
RiErrorWarningLine,
RiFileCopyLine,
RiFileEditLine,
RiFolderLine,
RiLinkUnlinkM,
RiMore2Line,
RiPencilAiLine,
RiPushpinLine,
RiRobot2Line,
RiShare2Line,
RiShieldLine,
RiUnpinLine,
RiGitBranchLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { isVSCodeRuntime } from '@/lib/desktop';
import { DraggableSessionRow } from './sessionFolderDnd';
import type { SessionNode, SessionSummaryMeta } from './types';
import { formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
@@ -48,6 +47,11 @@ const getAttentionDiamondDelay = (index: number): string => {
type Folder = { id: string; name: string; sessionIds: string[] };
type SecondaryMeta = {
projectLabel?: string | null;
branchLabel?: string | null;
};
type Props = {
node: SessionNode;
depth?: number;
@@ -79,8 +83,8 @@ type Props = {
copiedSessionId: string | null;
handleCopyShareUrl: (url: string, sessionId: string) => void;
handleUnshareSession: (sessionId: string) => void;
openMenuSessionId: string | null;
setOpenMenuSessionId: (id: string | null) => void;
openSidebarMenuKey: string | null;
setOpenSidebarMenuKey: (key: string | null) => void;
renamingFolderId: string | null;
getFoldersForScope: (scopeKey: string) => Folder[];
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
@@ -90,7 +94,9 @@ type Props = {
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string }) => void;
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void;
mobileVariant: boolean;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: SecondaryMeta | null, renderContext?: 'project' | 'recent') => React.ReactNode;
secondaryMeta?: SecondaryMeta | null;
renderContext?: 'project' | 'recent';
};
export function SessionNodeItem(props: Props): React.ReactNode {
@@ -125,8 +131,8 @@ export function SessionNodeItem(props: Props): React.ReactNode {
copiedSessionId,
handleCopyShareUrl,
handleUnshareSession,
openMenuSessionId,
setOpenMenuSessionId,
openSidebarMenuKey,
setOpenSidebarMenuKey,
renamingFolderId,
getFoldersForScope,
getSessionFolderId,
@@ -137,13 +143,19 @@ export function SessionNodeItem(props: Props): React.ReactNode {
handleDeleteSession,
mobileVariant,
renderSessionNode,
secondaryMeta,
renderContext = 'project',
} = props;
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
const displayMode = useSessionDisplayStore((state) => state.displayMode);
const isMinimalMode = displayMode === 'minimal';
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const suppressNextSelectRef = React.useRef(false);
const session = node.session;
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(groupDirectory ?? null);
@@ -160,12 +172,16 @@ export function SessionNodeItem(props: Props): React.ReactNode {
const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks);
const sessionSummary = session.summary as SessionSummaryMeta | undefined;
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
const sessionTimestamp = session.time?.updated || session.time?.created || Date.now();
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
if (editingId === session.id) {
return (
<div
key={session.id}
className={cn('group relative flex items-center rounded-md px-1.5 py-1', 'bg-interactive-selection', depth > 0 && 'pl-[20px]')}
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', depth > 0 && 'pl-[20px]')}
>
<div className="flex min-w-0 flex-1 flex-col gap-0">
<form
@@ -197,17 +213,14 @@ export function SessionNodeItem(props: Props): React.ReactNode {
<button type="button" onClick={handleCancelEdit} className="shrink-0 text-muted-foreground hover:text-foreground"><RiCloseLine className="size-4" /></button>
</form>
{!isMinimalMode ? (
<div className="flex items-center gap-2 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
{hasChildren ? <span className="inline-flex items-center justify-center flex-shrink-0">{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}</span> : null}
<span className="flex-shrink-0">{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}</span>
{sessionDiffStats ? <span className="flex-shrink-0"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-muted-foreground/60">/</span><span className="text-status-error/80">-{sessionDiffStats.deletions}</span></span> : null}
{session.share ? <RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" /> : null}
{(sessionSummary?.files ?? 0) > 0 || hasChildren ? (
<span className="flex items-center gap-2 flex-shrink-0">
{(sessionSummary?.files ?? 0) > 0 ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiFileEditLine className="h-3 w-3 text-muted-foreground/70" /><span>{sessionSummary!.files}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}</p></TooltipContent></Tooltip> : null}
{hasChildren ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiRobot2Line className="h-3 w-3 text-muted-foreground/70" /><span>{node.children.length}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}</p></TooltipContent></Tooltip> : null}
</span>
) : null}
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
{hasChildren ? <span className="inline-flex items-center justify-center flex-shrink-0">{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}</span> : null}
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
{sessionDiffStats ? <span className="flex flex-shrink-0 items-center gap-0 text-[0.92em]"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-status-error/65">/-{sessionDiffStats.deletions}</span></span> : null}
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><RiGitBranchLine className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
</div>
</div>
) : null}
</div>
@@ -220,21 +233,191 @@ export function SessionNodeItem(props: Props): React.ReactNode {
const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
const showStatusMarker = isStreaming || showUnreadStatus;
const statusMarkerContent = isStreaming
? <GridLoader size="xs" className="text-primary" />
: (
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
{Array.from({ length: 9 }, (_, i) => (
ATTENTION_DIAMOND_INDICES.has(i) ? (
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
) : (
<span key={i} className="h-[3px] w-[3px]" />
)
))}
</span>
);
const inlineStatusMarker = !isMinimalMode && showStatusMarker ? (
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
{statusMarkerContent}
</span>
) : null;
const minimalLeadingStatusMarker = isMinimalMode && showStatusMarker ? (
<span
className={cn(
'pointer-events-none absolute left-[-10px] top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center transition-opacity',
hasChildren ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
)}
>
{statusMarkerContent}
</span>
) : null;
const subsessionChevron = hasChildren ? (
<span
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
toggleParent(session.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
toggleParent(session.id);
}
}}
className={cn(
'absolute left-[-10px] top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
isMinimalMode && showStatusMarker
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'
: '',
)}
aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}
>
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
</span>
) : null;
const streamingIndicator = memoryState?.isZombie
? <RiErrorWarningLine className="h-4 w-4 text-status-warning" />
: null;
const handleMenuOpenChange = (open: boolean) => {
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
};
const handleMenuTriggerClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
const handleRowSelect = () => {
if (suppressNextSelectRef.current) {
suppressNextSelectRef.current = false;
return;
}
handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId);
};
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
suppressNextSelectRef.current = true;
}
};
const sessionMenuContent = (
<DropdownMenuContent align="end" className="min-w-[180px]" onCloseAutoFocus={(event) => { if (renamingFolderId) event.preventDefault(); }}>
<DropdownMenuItem
onClick={() => {
setEditingId(session.id);
setEditTitle(sessionTitle);
}}
className="[&>svg]:mr-1"
>
<RiPencilAiLine className="mr-1 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
{isPinnedSession ? 'Unpin session' : 'Pin session'}
</DropdownMenuItem>
{!session.share ? (
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
<RiShare2Line className="mr-1 h-4 w-4" />
Share
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1">
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
<RiLinkUnlinkM className="mr-1 h-4 w-4" />
Unshare
</DropdownMenuItem>
</>
)}
{sessionDirectory && !archivedBucket ? (() => {
const scopeFolders = getFoldersForScope(sessionDirectory);
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
return (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />Move to folder</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">No folders yet</DropdownMenuItem>
) : (
scopeFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
<span className="flex-1 truncate">{folder.name}</span>
{currentFolderId === folder.id ? <RiCheckLine className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
<RiAddLine className="mr-1 h-4 w-4" />
New folder...
</DropdownMenuItem>
{currentFolderId ? (
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
<RiCloseLine className="mr-1 h-4 w-4" />
Remove from folder
</DropdownMenuItem>
) : null}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
);
})() : null}
{!isVSCode ? (
<DropdownMenuItem
disabled={!sessionDirectory}
onClick={() => {
if (!sessionDirectory) return;
openContextPanelTab(sessionDirectory, {
mode: 'chat',
dedupeKey: `session:${session.id}`,
label: sessionTitle,
});
}}
className="[&>svg]:mr-1"
>
<RiChat4Line className="mr-1 h-4 w-4" />
<span className="truncate">Open in Side Panel</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
<RiDeleteBinLine className="mr-1 h-4 w-4" />
{archivedBucket ? 'Delete' : 'Archive'}
</DropdownMenuItem>
</DropdownMenuContent>
);
return (
<React.Fragment key={session.id}>
<DraggableSessionRow sessionId={session.id} sessionDirectory={sessionDirectory ?? null} sessionTitle={sessionTitle}>
<div
className={cn('group relative flex items-center rounded-md px-1.5 py-1', isActive ? 'bg-interactive-selection' : 'hover:bg-interactive-hover', isMissingDirectory ? 'opacity-75' : '', depth > 0 && 'pl-[20px]')}
onContextMenu={(e) => {
e.preventDefault();
setOpenMenuSessionId(session.id);
}}
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', isMissingDirectory ? 'opacity-75' : '', depth > 0 && 'pl-[20px]')}
>
{minimalLeadingStatusMarker}
{subsessionChevron}
<div className="flex min-w-0 flex-1 items-center">
{isMinimalMode ? (
<Tooltip>
@@ -242,38 +425,52 @@ export function SessionNodeItem(props: Props): React.ReactNode {
<button
type="button"
disabled={isMissingDirectory}
onClick={() => handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId)}
onMouseDown={handleRowMouseDown}
onClick={handleRowSelect}
onDoubleClick={(e) => {
e.stopPropagation();
handleSessionDoubleClick();
}}
className={cn('flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]', mobileVariant ? 'pr-7' : 'group-hover:pr-5 group-focus-within:pr-5')}
className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
mobileVariant ? 'pr-7' : '',
)}
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-2')}>
{isMinimalMode && hasChildren ? (
<span role="button" tabIndex={0} onClick={(event) => { event.stopPropagation(); toggleParent(session.id); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); toggleParent(session.id); } }} className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 flex-shrink-0 rounded-sm" aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}>
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
</span>
) : null}
{showStatusMarker ? (
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
{isStreaming ? (
<GridLoader size="xs" className="text-primary" />
) : (
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
{Array.from({ length: 9 }, (_, i) => (
ATTENTION_DIAMOND_INDICES.has(i) ? (
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
) : (
<span key={i} className="h-[3px] w-[3px]" />
)
))}
</span>
)}
</span>
) : null}
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{mobileVariant ? <span className="ml-2 flex-shrink-0 text-[0.72rem] text-muted-foreground/75">{sessionCompactUpdatedLabel}</span> : null}
{!mobileVariant ? (
<div className="relative ml-1 flex h-4 min-w-4 flex-shrink-0 items-center justify-end">
<span className={cn(
'whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
isMenuOpen
? 'opacity-0'
: 'group-hover:opacity-0 group-focus-within:opacity-0',
)}>
{sessionCompactUpdatedLabel}
</span>
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'absolute inset-y-0 right-0 inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
isMenuOpen
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
)}
aria-label="Session menu"
onClick={handleMenuTriggerClick}
onKeyDown={(event) => event.stopPropagation()}
>
<RiMore2Line className="h-2.5 w-2.5" />
</button>
</DropdownMenuTrigger>
{sessionMenuContent}
</DropdownMenu>
</div>
) : null}
{pendingPermissionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
<RiShieldLine className="h-3 w-3" />
@@ -283,40 +480,20 @@ export function SessionNodeItem(props: Props): React.ReactNode {
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="max-w-xs">
<div className="flex flex-col gap-1 text-xs">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}</span>
{sessionDiffStats ? (
<span className="flex items-center gap-1">
<span className="text-status-success">+{sessionDiffStats.additions}</span>
<span className="text-muted-foreground">/</span>
<span className="text-status-error">-{sessionDiffStats.deletions}</span>
</span>
) : null}
<TooltipContent side="right" sideOffset={8} className="max-w-xs text-left">
<div className="flex flex-col gap-1 text-left text-xs">
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.projectLabel ? 'justify-between' : 'justify-start')}>
{secondaryMeta?.projectLabel ? <div className="min-w-0 truncate">{secondaryMeta.projectLabel}</div> : null}
<div className="flex-shrink-0">{sessionUpdatedLabel}</div>
</div>
{session.share ? (
<div className="flex items-center gap-1 text-[color:var(--status-info)]">
<RiShare2Line className="h-3 w-3" />
<span>Shared session</span>
</div>
) : null}
{(sessionSummary?.files ?? 0) > 0 ? (
<div className="flex items-center gap-1">
<RiFileEditLine className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}</span>
</div>
) : null}
{hasChildren ? (
<div className="flex items-center gap-1">
<RiRobot2Line className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}</span>
</div>
) : null}
{isMissingDirectory ? (
<div className="flex items-center gap-1 text-status-warning">
<RiErrorWarningLine className="h-3 w-3" />
<span>Directory missing</span>
{secondaryMeta?.branchLabel || sessionDiffStats ? (
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.branchLabel ? 'justify-between' : 'justify-start')}>
{secondaryMeta?.branchLabel ? (
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<span className="inline-flex min-w-0 items-center gap-0.5"><RiGitBranchLine className="h-3 w-3 flex-shrink-0" /><span className="truncate">{secondaryMeta.branchLabel}</span></span>
</div>
) : null}
{sessionDiffStats ? <span className="flex flex-shrink-0 items-center gap-0.5"><span className="text-status-success">+{sessionDiffStats.additions}</span><span className="text-status-error">-{sessionDiffStats.deletions}</span></span> : null}
</div>
) : null}
</div>
@@ -326,176 +503,85 @@ export function SessionNodeItem(props: Props): React.ReactNode {
<button
type="button"
disabled={isMissingDirectory}
onClick={() => handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId)}
onMouseDown={handleRowMouseDown}
onClick={handleRowSelect}
onDoubleClick={(e) => {
e.stopPropagation();
handleSessionDoubleClick();
}}
className={cn('flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]', mobileVariant ? 'pr-7' : 'group-hover:pr-5 group-focus-within:pr-5')}
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-2')}>
{showStatusMarker ? (
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
{isStreaming ? (
<GridLoader size="xs" className="text-primary" />
) : (
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
{Array.from({ length: 9 }, (_, i) => (
ATTENTION_DIAMOND_INDICES.has(i) ? (
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
) : (
<span key={i} className="h-[3px] w-[3px]" />
)
))}
</span>
)}
</span>
) : null}
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{pendingPermissionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
<RiShieldLine className="h-3 w-3" />
<span className="leading-none">{pendingPermissionCount}</span>
</span>
) : null}
</div>
{!isMinimalMode ? (
<div className="flex items-center gap-2 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
{hasChildren ? (
<span role="button" tabIndex={0} onClick={(event) => { event.stopPropagation(); toggleParent(session.id); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); toggleParent(session.id); } }} className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 flex-shrink-0 rounded-sm" aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}>
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
</span>
) : null}
<span className="flex-shrink-0">{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}</span>
{sessionDiffStats ? <span className="flex-shrink-0"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-muted-foreground/60">/</span><span className="text-status-error/80">-{sessionDiffStats.deletions}</span></span> : null}
{session.share ? <RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" /> : null}
{(sessionSummary?.files ?? 0) > 0 || hasChildren ? (
<span className="flex items-center gap-2 flex-shrink-0">
{(sessionSummary?.files ?? 0) > 0 ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiFileEditLine className="h-3 w-3 text-muted-foreground/70" /><span>{sessionSummary!.files}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}</p></TooltipContent></Tooltip> : null}
{hasChildren ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiRobot2Line className="h-3 w-3 text-muted-foreground/70" /><span>{node.children.length}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}</p></TooltipContent></Tooltip> : null}
</span>
) : null}
{isMissingDirectory ? <span className="inline-flex items-center gap-0.5 text-status-warning flex-shrink-0"><RiErrorWarningLine className="h-3 w-3" />Missing</span> : null}
</div>
) : null}
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
{inlineStatusMarker}
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{pendingPermissionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
<RiShieldLine className="h-3 w-3" />
<span className="leading-none">{pendingPermissionCount}</span>
</span>
) : null}
</div>
{!isMinimalMode ? (
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
{sessionDiffStats ? <span className="flex flex-shrink-0 items-center gap-0 text-[0.92em]"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-muted-foreground/60">/</span><span className="text-status-error/65">-{sessionDiffStats.deletions}</span></span> : null}
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><RiGitBranchLine className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
</div>
</div>
) : null}
</button>
)}
</div>
{streamingIndicator && !mobileVariant ? (
<div className={cn('absolute top-1/2 -translate-y-1/2 z-10', isMinimalMode ? 'right-7' : 'right-[30px]')}>
<div className={cn('absolute top-1/2 -translate-y-1/2 z-10', isMinimalMode ? 'right-0' : 'right-[30px]')}>
{streamingIndicator}
</div>
) : null}
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', mobileVariant ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100')}>
<DropdownMenu open={openMenuSessionId === session.id} onOpenChange={(open) => setOpenMenuSessionId(open ? session.id : null)}>
<DropdownMenuTrigger asChild>
<button type="button" className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" aria-label="Session menu" onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<RiMore2Line className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]" onCloseAutoFocus={(event) => { if (renamingFolderId) event.preventDefault(); }}>
<DropdownMenuItem
onClick={() => {
setEditingId(session.id);
setEditTitle(sessionTitle);
}}
className="[&>svg]:mr-1"
>
<RiPencilAiLine className="mr-1 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
{isPinnedSession ? 'Unpin session' : 'Pin session'}
</DropdownMenuItem>
{!session.share ? (
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
<RiShare2Line className="mr-1 h-4 w-4" />
Share
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1">
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
<RiLinkUnlinkM className="mr-1 h-4 w-4" />
Unshare
</DropdownMenuItem>
</>
)}
{sessionDirectory && !archivedBucket ? (() => {
const scopeFolders = getFoldersForScope(sessionDirectory);
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
return (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />Move to folder</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">No folders yet</DropdownMenuItem>
) : (
scopeFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
<span className="flex-1 truncate">{folder.name}</span>
{currentFolderId === folder.id ? <RiCheckLine className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
<RiAddLine className="mr-1 h-4 w-4" />
New folder...
</DropdownMenuItem>
{currentFolderId ? (
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
<RiCloseLine className="mr-1 h-4 w-4" />
Remove from folder
</DropdownMenuItem>
) : null}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
);
})() : null}
{!isVSCode ? (
<DropdownMenuItem
disabled={!sessionDirectory}
onClick={() => {
if (!sessionDirectory) return;
openContextPanelTab(sessionDirectory, {
mode: 'chat',
dedupeKey: `session:${session.id}`,
label: sessionTitle,
});
}}
className="[&>svg]:mr-1"
{!isMinimalMode || mobileVariant ? (
<div className={cn(
'absolute right-0 top-1/2 z-10 -translate-y-1/2',
cn(
'transition-opacity',
isMenuOpen
? 'opacity-100 pointer-events-auto'
: mobileVariant
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
),
)}>
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
isMinimalMode && !mobileVariant
? (isMenuOpen
? 'h-4 w-4 opacity-100 pointer-events-auto'
: 'h-4 w-4 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto')
: 'h-6 w-6 opacity-100',
)}
aria-label="Session menu"
onClick={handleMenuTriggerClick}
onKeyDown={(event) => event.stopPropagation()}
>
<RiChat4Line className="mr-1 h-4 w-4" />
<span className="truncate">Open in Side Panel</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
<RiDeleteBinLine className="mr-1 h-4 w-4" />
{archivedBucket ? 'Delete' : 'Archive'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<RiMore2Line className={cn(isMinimalMode && !mobileVariant ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
</button>
</DropdownMenuTrigger>
{sessionMenuContent}
</DropdownMenu>
</div>
) : null}
</div>
</DraggableSessionRow>
{hasChildren && isExpanded
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket))
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
: null}
</React.Fragment>
);
@@ -0,0 +1,110 @@
import React from 'react';
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { SessionNode } from './types';
type ActivityItem = {
node: SessionNode;
projectId: string | null;
groupDirectory: string | null;
secondaryMeta: {
projectLabel?: string | null;
branchLabel?: string | null;
} | null;
};
type ActivitySection = {
key: 'active-now';
title: string;
items: ActivityItem[];
};
type Props = {
sections: ActivitySection[];
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, renderContext?: 'project' | 'recent') => React.ReactNode;
};
const MAX_VISIBLE_RECENT_SESSIONS = 7;
export function SidebarActivitySections({ sections, renderSessionNode }: Props): React.ReactNode {
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
const [expandedSections, setExpandedSections] = React.useState<Set<string>>(new Set());
const toggleSection = React.useCallback((key: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const toggleSectionLimit = React.useCallback((key: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const visibleSections = sections.filter((section) => section.items.length > 0);
if (visibleSections.length === 0) {
return null;
}
return (
<div className="space-y-2 pb-2 pt-1">
{visibleSections.map((section) => {
const isCollapsed = collapsed.has(section.key);
const isExpanded = expandedSections.has(section.key);
const visibleItems = isExpanded ? section.items : section.items.slice(0, MAX_VISIBLE_RECENT_SESSIONS);
const remainingCount = section.items.length - visibleItems.length;
return (
<div key={section.key} className="space-y-1">
<button
type="button"
onClick={() => toggleSection(section.key)}
className="group flex w-full items-center gap-1 rounded-md px-0.5 py-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-expanded={!isCollapsed}
>
<span className="inline-flex h-4 w-4 items-center justify-center text-muted-foreground">
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
</span>
<span className="text-[14px] font-normal text-foreground/95">{section.title}</span>
</button>
{!isCollapsed ? (
<div className={cn('space-y-0.5 pl-7')}>
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
{remainingCount > 0 && !isExpanded ? (
<button
type="button"
onClick={() => toggleSectionLimit(section.key)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show {remainingCount} more {remainingCount === 1 ? 'session' : 'sessions'}
</button>
) : null}
{isExpanded && section.items.length > MAX_VISIBLE_RECENT_SESSIONS ? (
<button
type="button"
onClick={() => toggleSectionLimit(section.key)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show fewer sessions
</button>
) : null}
</div>
) : null}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,42 @@
import React from 'react';
import { RiInformationLine, RiQuestionLine, RiSettings3Line } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
type Props = {
onOpenSettings: () => void;
onOpenShortcuts: () => void;
onOpenAbout: () => void;
};
const footerButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
export function SidebarFooter({ onOpenSettings, onOpenShortcuts, onOpenAbout }: Props): React.ReactNode {
return (
<div className="flex shrink-0 items-center justify-start gap-1 px-2.5 py-2">
<Tooltip>
<TooltipTrigger asChild>
<button type="button" onClick={onOpenSettings} className={footerButtonClassName} aria-label="Settings">
<RiSettings3Line className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>Settings</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label="Shortcuts">
<RiQuestionLine className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>Shortcuts</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" onClick={onOpenAbout} className={footerButtonClassName} aria-label="About OpenChamber">
<RiInformationLine className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>About OpenChamber</p></TooltipContent>
</Tooltip>
</div>
);
}
@@ -7,62 +7,22 @@ import {
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
RiArrowDownSLine,
RiCheckLine,
RiCloseLine,
RiChatNewLine,
RiEqualizer2Line,
RiNodeTree,
RiPencilAiLine,
RiFolderAddLine,
RiSearchLine,
RiStickyNoteLine,
RiCloseLine,
} from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import { ProjectNotesTodoPanel } from '../ProjectNotesTodoPanel';
import { formatDirectoryName } from '@/lib/utils';
import { formatProjectLabel } from './utils';
import type { ProjectRef } from '@/lib/openchamberConfig';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
type ProjectItem = {
id: string;
label?: string;
normalizedPath: string;
};
type ActiveProject = {
id: string;
label?: string;
normalizedPath: string;
} | null;
type Props = {
hideDirectoryControls: boolean;
hideProjectSelector: boolean;
activeProjectForHeader: ActiveProject;
homeDirectory: string | null;
normalizedProjects: ProjectItem[];
activeProjectId: string | null;
setActiveProjectIdOnly: (projectId: string) => void;
isProjectRenameInline: boolean;
setIsProjectRenameInline: (value: boolean) => void;
handleStartInlineProjectRename: () => void;
handleSaveInlineProjectRename: () => void;
projectRenameDraft: string;
setProjectRenameDraft: (value: string) => void;
removeProject: (projectId: string) => void;
handleOpenDirectoryDialog: () => void;
addProjectButtonClass: string;
handleNewSession: () => void;
headerActionIconClass: string;
reserveHeaderActionsSpace: boolean;
stableActiveProjectIsRepo: boolean;
useMobileNotesPanel: boolean;
projectNotesPanelOpen: boolean;
setProjectNotesPanelOpen: (open: boolean) => void;
activeProjectRefForHeader: ProjectRef | null;
openMultiRunLauncher: () => void;
headerActionButtonClass: string;
setNewWorktreeDialogOpen: (open: boolean) => void;
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
isSessionSearchOpen: boolean;
setIsSessionSearchOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
@@ -75,31 +35,11 @@ type Props = {
export function SidebarHeader(props: Props): React.ReactNode {
const {
hideDirectoryControls,
hideProjectSelector,
activeProjectForHeader,
homeDirectory,
normalizedProjects,
activeProjectId,
setActiveProjectIdOnly,
isProjectRenameInline,
setIsProjectRenameInline,
handleStartInlineProjectRename,
handleSaveInlineProjectRename,
projectRenameDraft,
setProjectRenameDraft,
removeProject,
addProjectButtonClass,
handleOpenDirectoryDialog,
handleNewSession,
headerActionIconClass,
reserveHeaderActionsSpace,
stableActiveProjectIsRepo,
useMobileNotesPanel,
projectNotesPanelOpen,
setProjectNotesPanelOpen,
activeProjectRefForHeader,
openMultiRunLauncher,
headerActionButtonClass,
setNewWorktreeDialogOpen,
setActiveMainTab,
isSessionSearchOpen,
setIsSessionSearchOpen,
sessionSearchInputRef,
@@ -117,321 +57,129 @@ export function SidebarHeader(props: Props): React.ReactNode {
}
return (
<div className={`select-none pl-3.5 pr-2 flex-shrink-0 border-b border-border/60 ${hideProjectSelector ? 'py-1' : 'py-1.5'}`}>
{!hideProjectSelector && (
<div className="flex h-8 items-center justify-between gap-2">
<DropdownMenu
onOpenChange={(open) => {
if (!open) setIsProjectRenameInline(false);
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-8 min-w-0 max-w-[calc(100%-2.5rem)] cursor-pointer items-center gap-1 rounded-md px-2 text-left text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<span className="text-base font-semibold truncate">
{activeProjectForHeader
? formatProjectLabel(
activeProjectForHeader.label?.trim()
|| formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory)
|| activeProjectForHeader.normalizedPath,
)
: 'Projects'}
</span>
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[220px] max-w-[320px]">
{normalizedProjects.map((project) => {
const label = formatProjectLabel(
project.label?.trim()
|| formatDirectoryName(project.normalizedPath, homeDirectory)
|| project.normalizedPath,
);
return (
<DropdownMenuItem
key={project.id}
onClick={() => setActiveProjectIdOnly(project.id)}
className={`truncate ${project.id === activeProjectId ? 'text-primary' : ''}`}
>
<span className="truncate">{label}</span>
</DropdownMenuItem>
);
})}
<div className="my-1 h-px bg-border/70" />
{!isProjectRenameInline ? (
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
handleStartInlineProjectRename();
}}
className="gap-2"
>
<RiPencilAiLine className="h-4 w-4" />
Rename project
</DropdownMenuItem>
) : (
<div className="px-2 py-1.5">
<form
className="flex items-center gap-1"
onSubmit={(event) => {
event.preventDefault();
handleSaveInlineProjectRename();
}}
>
<input
value={projectRenameDraft}
onChange={(event) => setProjectRenameDraft(event.target.value)}
className="h-7 flex-1 rounded border border-border bg-transparent px-2 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
placeholder="Rename project"
autoFocus
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
setIsProjectRenameInline(false);
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}}
/>
<button type="submit" className="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground">
<RiCheckLine className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setIsProjectRenameInline(false)}
className="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<RiCloseLine className="h-4 w-4" />
</button>
</form>
</div>
)}
<DropdownMenuItem
onClick={() => {
if (!activeProjectForHeader) return;
removeProject(activeProjectForHeader.id);
}}
className="text-destructive focus:text-destructive gap-2"
>
<RiCloseLine className="h-4 w-4" />
Close project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div className="select-none flex-shrink-0 px-2.5 py-1">
{reserveHeaderActionsSpace ? (
<div className="flex h-auto min-h-8 flex-col gap-1">
<div className="flex h-8 items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={addProjectButtonClass}
aria-label="Session display mode"
onClick={handleOpenDirectoryDialog}
className={headerActionButtonClass}
aria-label="Add project"
>
<RiEqualizer2Line className={headerActionIconClass} />
<RiFolderAddLine className={headerActionIconClass} />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem
onClick={() => setDisplayMode('default')}
className="flex items-center justify-between"
>
<span>Default</span>
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDisplayMode('minimal')}
className="flex items-center justify-between"
>
<span>Minimal</span>
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Add project</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleNewSession}
className={headerActionButtonClass}
aria-label="New session"
>
<RiChatNewLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New session</p></TooltipContent>
</Tooltip>
</div>
{reserveHeaderActionsSpace ? (
<div className="-ml-1 flex h-auto min-h-8 flex-col gap-1">
{activeProjectForHeader ? (
<>
<div className="flex h-8 -translate-y-px items-center justify-between gap-1.5 rounded-md pl-0 pr-1">
<div className="flex items-center gap-1.5">
{stableActiveProjectIsRepo ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={async () => {
if (!activeProjectForHeader) return;
if (activeProjectForHeader.id !== activeProjectId) {
setActiveProjectIdOnly(activeProjectForHeader.id);
}
setActiveMainTab('chat');
setNewWorktreeDialogOpen(true);
}}
className={headerActionButtonClass}
aria-label="New worktree"
>
<RiNodeTree className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New worktree</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={openMultiRunLauncher}
className={headerActionButtonClass}
aria-label="New multi-run"
>
<ArrowsMerge className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
</Tooltip>
</>
) : null}
{useMobileNotesPanel ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setProjectNotesPanelOpen(true)}
className={headerActionButtonClass}
aria-label="Project notes and todos"
>
<RiStickyNoteLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
</Tooltip>
) : (
<DropdownMenu open={projectNotesPanelOpen} onOpenChange={setProjectNotesPanelOpen} modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className={headerActionButtonClass}
aria-label="Project notes and todos"
>
<RiStickyNoteLine className={headerActionIconClass} />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-[340px] p-0">
<ProjectNotesTodoPanel
projectRef={activeProjectRefForHeader}
canCreateWorktree={stableActiveProjectIsRepo}
onActionComplete={() => setProjectNotesPanelOpen(false)}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
className={headerActionButtonClass}
aria-label="Search sessions"
aria-expanded={isSessionSearchOpen}
>
<RiSearchLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Search sessions</p></TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
className={headerActionButtonClass}
aria-label="Search sessions"
aria-expanded={isSessionSearchOpen}
>
<RiSearchLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Search sessions</p></TooltipContent>
</Tooltip>
</div>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className={headerActionButtonClass}
aria-label="Session display mode"
>
<RiEqualizer2Line className={headerActionIconClass} />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem
onClick={() => setDisplayMode('default')}
className="flex items-center justify-between"
>
<span>Default</span>
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDisplayMode('minimal')}
className="flex items-center justify-between"
>
<span>Minimal</span>
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isSessionSearchOpen ? (
<div className="px-1 pb-1">
<div className="mb-1 flex items-center justify-between px-0.5 typography-micro text-muted-foreground/80">
{hasSessionSearchQuery ? (
<span>{searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'}</span>
) : <span />}
<span>Esc to clear</span>
</div>
<div className="relative">
<RiSearchLine className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
ref={sessionSearchInputRef}
value={sessionSearchQuery}
onChange={(event) => setSessionSearchQuery(event.target.value)}
placeholder="Search sessions..."
className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
if (hasSessionSearchQuery) {
setSessionSearchQuery('');
} else {
setIsSessionSearchOpen(false);
}
}
}}
/>
{sessionSearchQuery.length > 0 ? (
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={() => setSessionSearchQuery('')}
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Clear search"
className={headerActionButtonClass}
aria-label="Session display mode"
>
<RiCloseLine className="h-3.5 w-3.5" />
<RiEqualizer2Line className={headerActionIconClass} />
</button>
) : null}
</div>
</div>
) : null}
</>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem
onClick={() => setDisplayMode('default')}
className="flex items-center justify-between"
>
<span>Default</span>
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDisplayMode('minimal')}
className="flex items-center justify-between"
>
<span>Minimal</span>
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{isSessionSearchOpen ? (
<div className="pb-1">
<div className="mb-1 flex items-center justify-between px-0.5 typography-micro text-muted-foreground/80">
{hasSessionSearchQuery ? (
<span>{searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'}</span>
) : <span />}
<span>Esc to clear</span>
</div>
<div className="relative">
<RiSearchLine className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
ref={sessionSearchInputRef}
value={sessionSearchQuery}
onChange={(event) => setSessionSearchQuery(event.target.value)}
placeholder="Search sessions..."
className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
if (hasSessionSearchQuery) {
setSessionSearchQuery('');
} else {
setIsSessionSearchOpen(false);
}
}
}}
/>
{sessionSearchQuery.length > 0 ? (
<button
type="button"
onClick={() => setSessionSearchQuery('')}
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Clear search"
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
) : null}
</div>
</div>
) : null}
</div>
) : null}
@@ -12,6 +12,7 @@ import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSo
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
import type { SessionGroup } from './types';
import type { SortableDragHandleProps } from './sortableItems';
import { SortableGroupItem, SortableProjectItem } from './sortableItems';
import { formatProjectLabel } from './utils';
@@ -20,11 +21,16 @@ type ProjectSection = {
id: string;
label?: string;
normalizedPath: string;
icon?: string;
color?: string;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
iconBackground?: string;
};
groups: SessionGroup[];
};
type Props = {
topContent?: React.ReactNode;
sectionsForRender: ProjectSection[];
projectSections: ProjectSection[];
activeProjectId: string | null;
@@ -32,7 +38,7 @@ type Props = {
hasSessionSearchQuery: boolean;
emptyState: React.ReactNode;
searchEmptyState: React.ReactNode;
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => React.ReactNode;
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null) => React.ReactNode;
homeDirectory: string | null;
collapsedProjects: Set<string>;
hideDirectoryControls: boolean;
@@ -47,38 +53,39 @@ type Props = {
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
setSessionSwitcherOpen: (open: boolean) => void;
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
createWorktreeSession: () => void;
openNewWorktreeDialog: () => void;
openMultiRunLauncher: () => void;
setEditingProjectId: (id: string | null) => void;
setEditProjectTitle: (title: string) => void;
editingProjectId: string | null;
editProjectTitle: string;
handleSaveProjectEdit: () => void;
handleCancelProjectEdit: () => void;
openProjectEditDialog: (id: string) => void;
removeProject: (id: string) => void;
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
settingsAutoCreateWorktree: boolean;
reorderProjects: (fromIndex: number, toIndex: number) => void;
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
openSidebarMenuKey: string | null;
setOpenSidebarMenuKey: (key: string | null) => void;
isInlineEditing: boolean;
};
export function SidebarProjectsList(props: Props): React.ReactNode {
const sensors = useSensors(
const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const groupSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
if (props.projectSections.length === 0) {
return <ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>{props.emptyState}</ScrollableOverlay>;
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.topContent}{props.emptyState}</ScrollableOverlay>;
}
if (props.sectionsForRender.length === 0) {
return <ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>{props.searchEmptyState}</ScrollableOverlay>;
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.searchEmptyState}</ScrollableOverlay>;
}
return (
<ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>
{props.topContent}
{props.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1">
{(() => {
@@ -113,117 +120,128 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
</div>
) : (
<>
{props.sectionsForRender.map((section) => {
const project = section.project;
const projectKey = project.id;
const projectLabel = formatProjectLabel(
project.label?.trim()
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|| project.normalizedPath,
);
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
const isCollapsed = props.collapsedProjects.has(projectKey) && props.hideDirectoryControls;
const isActiveProject = projectKey === props.activeProjectId;
const isRepo = props.projectRepoStatus.get(projectKey);
const isHovered = props.hoveredProjectId === projectKey;
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
const sortableEntries = orderedGroups.map((group) => ({
sortableId: `${projectKey}:${group.id}`,
groupId: group.id,
}));
const sortableGroupIds = sortableEntries.map((entry) => entry.sortableId);
const sortableIdToGroupId = new Map(sortableEntries.map((entry) => [entry.sortableId, entry.groupId]));
<DndContext
sensors={projectSensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
if (props.isInlineEditing) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
props.reorderProjects(oldIndex, newIndex);
}}
>
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
{props.sectionsForRender.map((section) => {
const project = section.project;
const projectKey = project.id;
const projectLabel = formatProjectLabel(
project.label?.trim()
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|| project.normalizedPath,
);
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
const isCollapsed = props.collapsedProjects.has(projectKey);
const isActiveProject = projectKey === props.activeProjectId;
const isHovered = props.hoveredProjectId === projectKey;
const isRepo = props.projectRepoStatus.get(projectKey);
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
const nestedGroups = rootGroup
? orderedGroups.filter((group) => group.id !== rootGroup.id)
: orderedGroups;
return (
<SortableProjectItem
key={projectKey}
id={projectKey}
projectLabel={projectLabel}
projectDescription={projectDescription}
isCollapsed={isCollapsed}
isActiveProject={isActiveProject}
isRepo={Boolean(isRepo)}
isHovered={isHovered}
isDesktopShell={props.isDesktopShellRuntime}
isStuck={props.stuckProjectHeaders.has(projectKey)}
hideDirectoryControls={props.hideDirectoryControls}
mobileVariant={props.mobileVariant}
onToggle={() => props.toggleProject(projectKey)}
onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)}
onNewSession={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.setActiveMainTab('chat');
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
props.openNewSessionDraft({ directoryOverride: project.normalizedPath });
}}
onNewWorktreeSession={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.setActiveMainTab('chat');
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
props.createWorktreeSession();
}}
onOpenMultiRunLauncher={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.openMultiRunLauncher();
}}
onRenameStart={() => {
props.setEditingProjectId(projectKey);
props.setEditProjectTitle(project.label?.trim() || formatDirectoryName(project.normalizedPath, props.homeDirectory) || project.normalizedPath);
}}
onRenameSave={props.handleSaveProjectEdit}
onRenameCancel={props.handleCancelProjectEdit}
onRenameValueChange={props.setEditProjectTitle}
renameValue={props.editingProjectId === projectKey ? props.editProjectTitle : ''}
isRenaming={props.editingProjectId === projectKey}
onClose={() => props.removeProject(projectKey)}
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
settingsAutoCreateWorktree={props.settingsAutoCreateWorktree}
showCreateButtons={false}
hideHeader
>
{!isCollapsed ? (
<div className="space-y-[0.6rem] py-1">
{section.groups.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeId = typeof active.id === 'string' ? sortableIdToGroupId.get(active.id) : null;
const overId = typeof over.id === 'string' ? sortableIdToGroupId.get(over.id) : null;
if (!activeId || !overId) return;
const oldIndex = orderedGroups.findIndex((item) => item.id === activeId);
const newIndex = orderedGroups.findIndex((item) => item.id === overId);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
const next = arrayMove(orderedGroups, oldIndex, newIndex).map((item) => item.id);
props.setGroupOrderByProject((prev) => {
const map = new Map(prev);
map.set(projectKey, next);
return map;
});
}}
>
<SortableContext items={sortableGroupIds} strategy={verticalListSortingStrategy}>
{orderedGroups.map((group) => {
const groupKey = `${projectKey}:${group.id}`;
return (
<SortableGroupItem key={groupKey} id={groupKey} disabled={props.isInlineEditing}>
{props.renderGroupSessions(group, groupKey, projectKey)}
</SortableGroupItem>
);
})}
</SortableContext>
<DragOverlay dropAnimation={null} />
</DndContext>
) : (
<div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>
)}
</div>
) : null}
</SortableProjectItem>
);
})}
return (
<SortableProjectItem
key={projectKey}
id={projectKey}
projectLabel={projectLabel}
projectDescription={projectDescription}
projectIcon={project.icon}
projectColor={project.color}
projectIconImage={project.iconImage}
projectIconBackground={project.iconBackground}
isCollapsed={isCollapsed}
isActiveProject={isActiveProject}
isHovered={isHovered}
isRepo={Boolean(isRepo)}
isDesktopShell={props.isDesktopShellRuntime}
isStuck={props.stuckProjectHeaders.has(projectKey)}
hideDirectoryControls={props.hideDirectoryControls}
mobileVariant={props.mobileVariant}
onToggle={() => props.toggleProject(projectKey)}
onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)}
onNewSession={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.setActiveMainTab('chat');
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
props.openNewSessionDraft({ directoryOverride: project.normalizedPath });
}}
onNewWorktreeSession={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.setActiveMainTab('chat');
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
props.openNewWorktreeDialog();
}}
onOpenMultiRunLauncher={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.openMultiRunLauncher();
}}
onRenameStart={() => props.openProjectEditDialog(projectKey)}
onClose={() => props.removeProject(projectKey)}
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
showCreateButtons
openSidebarMenuKey={props.openSidebarMenuKey}
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
>
{!isCollapsed ? (
<div className="space-y-0 pt-0 pb-0.5 pl-3">
{section.groups.length > 0 ? (
<DndContext
sensors={groupSensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
if (props.isInlineEditing) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = nestedGroups.findIndex((item) => item.id === active.id);
const newIndex = nestedGroups.findIndex((item) => item.id === over.id);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id);
const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested;
props.setGroupOrderByProject((prev) => {
const map = new Map(prev);
map.set(projectKey, next);
return map;
});
}}
>
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true) : null}
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
{nestedGroups.map((group) => {
const groupKey = `${projectKey}:${group.id}`;
return (
<SortableGroupItem key={group.id} id={group.id} disabled={props.isInlineEditing}>
{(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps)}
</SortableGroupItem>
);
})}
</SortableContext>
<DragOverlay dropAnimation={null} />
</DndContext>
) : (
<div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>
)}
</div>
) : null}
</SortableProjectItem>
);
})}
</SortableContext>
<DragOverlay dropAnimation={null} />
</DndContext>
</>
)}
</ScrollableOverlay>
@@ -0,0 +1,107 @@
import type { Session } from '@opencode-ai/sdk/v2';
export const ACTIVE_NOW_STORAGE_KEY = 'oc.sessions.activeNow';
export const ACTIVE_NOW_MAX_AGE_MS = 36 * 60 * 60 * 1000;
export type ActiveNowEntry = {
sessionId: string;
};
const isSubtaskSession = (session: Session): boolean => {
return Boolean((session as Session & { parentID?: string | null }).parentID);
};
const isArchivedSession = (session: Session): boolean => {
return Boolean(session.time?.archived);
};
const getSessionUpdatedAt = (session: Session): number => {
const updated = session.time?.updated;
const created = session.time?.created;
if (typeof updated === 'number' && Number.isFinite(updated)) {
return updated;
}
if (typeof created === 'number' && Number.isFinite(created)) {
return created;
}
return 0;
};
export const readActiveNowEntries = (storage: Storage): ActiveNowEntry[] => {
try {
const raw = storage.getItem(ACTIVE_NOW_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
const seen = new Set<string>();
const next: ActiveNowEntry[] = [];
parsed.forEach((item) => {
const sessionId = typeof item === 'string'
? item
: (item && typeof item === 'object' && 'sessionId' in item && typeof item.sessionId === 'string' ? item.sessionId : null);
if (!sessionId || seen.has(sessionId)) {
return;
}
seen.add(sessionId);
next.push({ sessionId });
});
return next;
} catch {
return [];
}
};
export const persistActiveNowEntries = (storage: Storage, entries: ActiveNowEntry[]): void => {
try {
storage.setItem(ACTIVE_NOW_STORAGE_KEY, JSON.stringify(entries));
} catch {
// ignored
}
};
export const pruneActiveNowEntries = (
entries: ActiveNowEntry[],
sessionsById: Map<string, Session>,
now = Date.now(),
): ActiveNowEntry[] => {
const minUpdatedAt = now - ACTIVE_NOW_MAX_AGE_MS;
return entries.filter((entry) => {
const session = sessionsById.get(entry.sessionId);
if (!session) {
return true;
}
if (isArchivedSession(session)) {
return false;
}
return getSessionUpdatedAt(session) >= minUpdatedAt;
});
};
export const addActiveNowSession = (entries: ActiveNowEntry[], sessionId: string): ActiveNowEntry[] => {
if (!sessionId || entries.some((entry) => entry.sessionId === sessionId)) {
return entries;
}
return [{ sessionId }, ...entries];
};
export const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
return [...sessions].sort((a, b) => getSessionUpdatedAt(b) - getSessionUpdatedAt(a));
};
export const deriveActiveNowSessions = (
entries: ActiveNowEntry[],
sessionsById: Map<string, Session>,
): Session[] => {
const sessions = entries
.map((entry) => sessionsById.get(entry.sessionId) ?? null)
.filter((session): session is Session => Boolean(session))
.filter((session) => !isArchivedSession(session))
.filter((session) => !isSubtaskSession(session));
return sortSessionsByUpdated(sessions);
};
export const getSessionUpdatedAtMs = getSessionUpdatedAt;
@@ -4,13 +4,11 @@ import type { SessionGroup } from '../types';
export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) => {
const getOrderedGroups = React.useCallback(
(projectId: string, groups: SessionGroup[]) => {
const archivedGroup = groups.find((group) => group.isArchivedBucket === true) ?? null;
const reorderableGroups = archivedGroup ? groups.filter((group) => group !== archivedGroup) : groups;
const preferredOrder = groupOrderByProject.get(projectId);
if (!preferredOrder || preferredOrder.length === 0) {
return archivedGroup ? [...reorderableGroups, archivedGroup] : reorderableGroups;
return groups;
}
const groupById = new Map(reorderableGroups.map((group) => [group.id, group]));
const groupById = new Map(groups.map((group) => [group.id, group]));
const ordered: SessionGroup[] = [];
preferredOrder.forEach((id) => {
const group = groupById.get(id);
@@ -19,12 +17,12 @@ export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) =>
groupById.delete(id);
}
});
reorderableGroups.forEach((group) => {
groups.forEach((group) => {
if (groupById.has(group.id)) {
ordered.push(group);
}
});
return archivedGroup ? [...ordered, archivedGroup] : ordered;
return ordered;
},
[groupOrderByProject],
);
@@ -15,7 +15,6 @@ type Args = {
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
currentSessionId: string | null;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void;
isVSCode: boolean;
newSessionDraftOpen: boolean;
mobileVariant: boolean;
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
@@ -33,7 +32,6 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
setActiveSessionByProject,
currentSessionId,
handleSessionSelect,
isVSCode,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
@@ -84,18 +82,13 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
}, [projectSections]);
const previousActiveProjectRef = React.useRef<string | null>(null);
const lastSeenActiveProjectRef = React.useRef<string | null>(null);
React.useLayoutEffect(() => {
if (!activeProjectId) {
return;
}
const previousSeenProjectId = lastSeenActiveProjectRef.current;
const isProjectSwitch = Boolean(previousSeenProjectId && previousSeenProjectId !== activeProjectId);
lastSeenActiveProjectRef.current = activeProjectId;
if (newSessionDraftOpen && (isVSCode || !isProjectSwitch)) {
if (newSessionDraftOpen) {
return;
}
@@ -146,7 +139,6 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
activeSessionByProject,
currentSessionId,
handleSessionSelect,
isVSCode,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
@@ -83,6 +83,11 @@ export const useSessionActions = (args: Args) => {
if (sessionId === args.currentSessionId) {
if (args.allowReselect) {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<string>('openchamber:session-reselected', {
detail: sessionId,
}));
}
args.onSessionSelected?.(sessionId);
}
resetSessionSearch();
@@ -19,6 +19,8 @@ type Args = {
isVSCode: boolean;
};
const isArchivedSession = (session: Session): boolean => Boolean(session.time?.archived);
export const useSessionGrouping = (args: Args) => {
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
@@ -70,6 +72,10 @@ export const useSessionGrouping = (args: Args) => {
sortedProjectSessions.forEach((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
if (!parentID) return;
const parentSession = sessionMap.get(parentID);
if (!parentSession || isArchivedSession(parentSession) !== isArchivedSession(session)) {
return;
}
const collection = childrenMap.get(parentID) ?? [];
collection.push(session);
childrenMap.set(parentID, collection);
@@ -105,7 +111,9 @@ export const useSessionGrouping = (args: Args) => {
const roots = sortedProjectSessions.filter((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
if (!parentID) return true;
return !sessionMap.has(parentID);
const parentSession = sessionMap.get(parentID);
if (!parentSession) return true;
return isArchivedSession(parentSession) !== isArchivedSession(session);
});
const groupedNodes = new Map<string, SessionNode[]>();
@@ -9,10 +9,11 @@ const SESSION_PREFETCH_PENDING_LIMIT = 6;
type Args = {
currentSessionId: string | null;
sortedSessions: Session[];
recentSessionIds?: string[];
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
};
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessages }: Args): void => {
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], loadMessages }: Args): void => {
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
@@ -100,6 +101,20 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessa
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]);
React.useEffect(() => {
if (!currentSessionId || recentSessionIds.length === 0) {
return;
}
const currentIndex = recentSessionIds.indexOf(currentSessionId);
if (currentIndex < 0) {
return;
}
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]);
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
}, [currentSessionId, recentSessionIds, scheduleSessionPrefetch]);
React.useEffect(() => {
const prefetchTimers = sessionPrefetchTimersRef.current;
return () => {
@@ -9,6 +9,10 @@ type ProjectItem = {
path: string;
label?: string;
normalizedPath: string;
icon?: string;
color?: string;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
iconBackground?: string;
};
type ProjectSection = {
@@ -18,7 +22,6 @@ type ProjectSection = {
type Args = {
normalizedProjects: ProjectItem[];
activeProjectId: string | null;
getSessionsForProject: (project: { normalizedPath: string }) => Session[];
getArchivedSessionsForProject: (project: { normalizedPath: string }) => Session[];
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
@@ -42,7 +45,6 @@ type Args = {
export const useSessionSidebarSections = (args: Args) => {
const {
normalizedProjects,
activeProjectId,
getSessionsForProject,
getArchivedSessionsForProject,
availableWorktreesByProject,
@@ -88,12 +90,8 @@ export const useSessionSidebarSections = (args: Args) => {
]);
const visibleProjectSections = React.useMemo(() => {
if (projectSections.length === 0) {
return projectSections;
}
const active = projectSections.find((section) => section.project.id === activeProjectId);
return active ? [active] : [projectSections[0]];
}, [projectSections, activeProjectId]);
return projectSections;
}, [projectSections]);
const groupSearchDataByGroup = React.useMemo(() => {
const result = new WeakMap<SessionGroup, GroupSearchData>();
@@ -10,23 +10,31 @@ import {
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import {
RiAddLine,
RiCheckLine,
RiArrowDownSLine,
RiArrowRightSLine,
RiCloseLine,
RiGitBranchLine,
RiFolderLine,
RiMore2Line,
RiNodeTree,
RiPencilAiLine,
} from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import { cn } from '@/lib/utils';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
export interface SortableProjectItemProps {
id: string;
projectLabel: string;
projectDescription: string;
projectIcon?: string;
projectColor?: string;
projectIconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
projectIconBackground?: string;
isCollapsed: boolean;
isActiveProject: boolean;
isRepo: boolean;
isHovered: boolean;
isRepo: boolean;
isDesktopShell: boolean;
isStuck: boolean;
hideDirectoryControls: boolean;
@@ -37,27 +45,32 @@ export interface SortableProjectItemProps {
onNewWorktreeSession?: () => void;
onOpenMultiRunLauncher: () => void;
onRenameStart: () => void;
onRenameSave: () => void;
onRenameCancel: () => void;
onRenameValueChange: (value: string) => void;
renameValue: string;
isRenaming: boolean;
onClose: () => void;
sentinelRef: (el: HTMLDivElement | null) => void;
children?: React.ReactNode;
settingsAutoCreateWorktree: boolean;
showCreateButtons?: boolean;
hideHeader?: boolean;
openSidebarMenuKey: string | null;
setOpenSidebarMenuKey: (key: string | null) => void;
}
export type SortableDragHandleProps = {
listeners: ReturnType<typeof useSortable>['listeners'];
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
};
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
id,
projectLabel,
projectDescription,
projectIcon,
projectColor,
projectIconImage,
projectIconBackground,
isCollapsed,
isActiveProject,
isRepo,
isHovered,
isRepo,
isDesktopShell,
isStuck,
hideDirectoryControls,
@@ -68,18 +81,15 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
onNewWorktreeSession,
onOpenMultiRunLauncher,
onRenameStart,
onRenameSave,
onRenameCancel,
onRenameValueChange,
renameValue,
isRenaming,
onClose,
sentinelRef,
children,
settingsAutoCreateWorktree,
showCreateButtons = true,
hideHeader = false,
openSidebarMenuKey,
setOpenSidebarMenuKey,
}) => {
const { currentTheme } = useThemeSystem();
const {
attributes,
listeners,
@@ -89,7 +99,45 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
isDragging,
} = useSortable({ id });
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const [imageFailed, setImageFailed] = React.useState(false);
const suppressNextToggleRef = React.useRef(false);
const menuInstanceKey = `project:${id}`;
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
React.useEffect(() => {
setImageFailed(false);
}, [id, projectIconImage?.updatedAt]);
const ProjectIcon = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null;
const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null;
const imageUrl = !imageFailed
? getProjectIconImageUrl({ id, iconImage: projectIconImage }, {
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
})
: null;
const handleMenuOpenChange = React.useCallback((open: boolean) => {
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
}, [menuInstanceKey, setOpenSidebarMenuKey]);
const handleMenuTriggerClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
}, []);
const handleToggleMouseDown = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
suppressNextToggleRef.current = true;
}
}, []);
const handleToggleClick = React.useCallback(() => {
if (suppressNextToggleRef.current) {
suppressNextToggleRef.current = false;
return;
}
onToggle();
}, [onToggle]);
return (
<div
@@ -110,123 +158,115 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
<div
className={cn(
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none',
!isDesktopShell && 'bg-transparent',
'w-full text-left group/project select-none',
)}
style={{
backgroundColor: isDesktopShell
? (isStuck ? 'transparent' : 'transparent')
: undefined,
borderColor: isHovered
? 'var(--color-border-hover)'
: isCollapsed
? 'color-mix(in srgb, var(--color-border) 35%, transparent)'
: 'var(--color-border)',
}}
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
onMouseEnter={() => onHoverChange(true)}
onMouseLeave={() => onHoverChange(false)}
onContextMenu={(event) => {
event.preventDefault();
if (!isRenaming) {
setIsMenuOpen(true);
}
}}
>
<div className="relative flex items-center gap-1 px-1" {...attributes}>
{isRenaming ? (
<form
className="flex min-w-0 flex-1 items-center gap-2"
data-keyboard-avoid="true"
onSubmit={(event) => {
event.preventDefault();
onRenameSave();
}}
>
<input
value={renameValue}
onChange={(event) => onRenameValueChange(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus
placeholder="Rename project"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
onRenameCancel();
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}}
/>
<button
type="submit"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<RiCheckLine className="size-4" />
</button>
<button
type="button"
onClick={onRenameCancel}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<RiCloseLine className="size-4" />
</button>
</form>
) : (
<Tooltip delayDuration={1500}>
<TooltipTrigger asChild>
<div className="relative flex items-center gap-1 px-0.5 py-0.5" {...attributes}>
<Tooltip delayDuration={1500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggle}
onMouseDown={handleToggleMouseDown}
onClick={handleToggleClick}
{...listeners}
className="flex-1 min-w-0 flex items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm cursor-grab active:cursor-grabbing"
className={cn(
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
isRepo && !hideDirectoryControls
? (mobileVariant ? 'pr-20' : isHovered ? 'pr-20' : 'pr-7')
: (mobileVariant ? 'pr-14' : isHovered ? 'pr-14' : 'pr-7'),
)}
>
<span className={cn(
'typography-ui font-semibold truncate',
isActiveProject ? 'text-primary' : 'text-foreground group-hover/project:text-foreground',
)}>
{projectLabel}
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<span className={cn('hidden text-muted-foreground h-3.5 w-3.5 items-center justify-center', isHovered && 'inline-flex')}>
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{projectDescription}
</TooltipContent>
</Tooltip>
)}
{imageUrl ? (
<span
className={cn('inline-flex h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px]', isHovered && 'hidden')}
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
>
<img
src={imageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setImageFailed(true)}
/>
</span>
) : ProjectIcon ? (
<ProjectIcon className={cn('h-3.5 w-3.5', isHovered && 'hidden')} style={iconColor ? { color: iconColor } : undefined} />
) : (
<RiFolderLine className={cn('h-3.5 w-3.5 text-muted-foreground/80', isHovered && 'hidden')} style={iconColor ? { color: iconColor } : undefined} />
)}
</span>
<span className={cn(
'text-[14px] font-normal truncate lowercase',
isActiveProject ? 'text-foreground' : 'text-foreground group-hover/project:text-foreground',
)}>
{projectLabel}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{projectDescription}
</TooltipContent>
</Tooltip>
<div className={cn(
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
isRepo && !hideDirectoryControls ? 'right-7' : 'right-0.5',
)}>
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNewWorktreeSession();
}}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground transition-opacity',
mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
aria-label="New worktree"
>
<RiNodeTree className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>New worktree...</p>
</TooltipContent>
</Tooltip>
) : null}
{!isRenaming ? (
<DropdownMenu
open={isMenuOpen}
onOpenChange={setIsMenuOpen}
onOpenChange={handleMenuOpenChange}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover/project:opacity-100',
)}
aria-label="Project menu"
onClick={(e) => e.stopPropagation()}
>
<RiMore2Line className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
{showCreateButtons && isRepo && !hideDirectoryControls && settingsAutoCreateWorktree && onNewSession && (
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
isMenuOpen ? 'opacity-100 pointer-events-auto' : mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
aria-label="Project menu"
onClick={handleMenuTriggerClick}
>
<RiMore2Line className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
{showCreateButtons && !isRepo && !hideDirectoryControls && onNewSession && (
<DropdownMenuItem onClick={onNewSession}>
<RiAddLine className="mr-1.5 h-4 w-4" />
New Session
</DropdownMenuItem>
)}
{showCreateButtons && isRepo && !hideDirectoryControls && !settingsAutoCreateWorktree && onNewWorktreeSession && (
<DropdownMenuItem onClick={onNewWorktreeSession}>
<RiGitBranchLine className="mr-1.5 h-4 w-4" />
New Session in Worktree
</DropdownMenuItem>
)}
{showCreateButtons && isRepo && !hideDirectoryControls && (
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
<ArrowsMerge className="mr-1.5 h-4 w-4" />
@@ -244,53 +284,35 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
<RiCloseLine className="mr-1.5 h-4 w-4" />
Close Project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession && settingsAutoCreateWorktree && !isRenaming && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNewWorktreeSession();
}}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground hover:bg-interactive-hover/50 flex-shrink-0',
mobileVariant ? 'opacity-70' : 'opacity-100',
)}
aria-label="New session in worktree"
>
<RiGitBranchLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>New session in worktree</p>
</TooltipContent>
</Tooltip>
)}
{showCreateButtons && (!settingsAutoCreateWorktree || !isRepo) && !isRenaming && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNewSession();
}}
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 flex-shrink-0 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="New session"
>
<RiAddLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>New session</p>
</TooltipContent>
</Tooltip>
)}
{showCreateButtons && onNewSession ? (
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNewSession();
}}
className={cn(
'h-6 w-6 rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
mobileVariant ? 'inline-flex items-center justify-center' : isHovered ? 'inline-flex items-center justify-center' : 'hidden',
)}
aria-label={isRepo ? 'New draft session' : 'New session'}
>
<RiAddLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>{isRepo ? 'New draft session' : 'New session'}</p>
</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
</div>
</>
@@ -304,17 +326,22 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
const SortableGroupItemBase: React.FC<{
id: string;
disabled?: boolean;
children: React.ReactNode;
children: React.ReactNode | ((dragHandleProps: SortableDragHandleProps) => React.ReactNode);
}> = ({ id, disabled = false, children }) => {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
const dragHandleProps = React.useMemo<SortableDragHandleProps>(() => ({
listeners,
setActivatorNodeRef,
}), [listeners, setActivatorNodeRef]);
return (
<div
ref={setNodeRef}
@@ -326,10 +353,8 @@ const SortableGroupItemBase: React.FC<{
'space-y-0.5 rounded-md',
isDragging && 'opacity-50',
)}
{...attributes}
{...listeners}
>
{children}
{typeof children === 'function' ? children(dragHandleProps) : children}
</div>
);
};
@@ -45,6 +45,34 @@ export const formatSessionDateLabel = (updatedMs: number): string => {
return formatDateLabel(updatedMs);
};
export const formatSessionCompactDateLabel = (updatedMs: number): string => {
const diff = Math.max(0, Date.now() - updatedMs);
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
const month = 30 * day;
const year = 365 * day;
if (diff < hour) {
return `${Math.max(1, Math.floor(diff / minute))}m`;
}
if (diff < day) {
return `${Math.floor(diff / hour)}h`;
}
if (diff < week) {
return `${Math.floor(diff / day)}d`;
}
if (diff < 5 * week) {
return `${Math.floor(diff / week)}w`;
}
if (diff < year) {
return `${Math.floor(diff / month)}mo`;
}
return `${Math.floor(diff / year)}y`;
};
export const normalizePath = (value?: string | null) => {
if (!value) {
return null;
@@ -5,11 +5,42 @@ import { Compartment, EditorState, RangeSetBuilder, StateField } from '@codemirr
import { Decoration, type DecorationSet, EditorView, type KeyBinding, ViewPlugin, WidgetType, gutters, keymap, lineNumbers } from '@codemirror/view';
import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands';
import { forceParsing, indentUnit } from '@codemirror/language';
import { search, searchKeymap, openSearchPanel, closeSearchPanel } from '@codemirror/search';
import { search, searchKeymap, openSearchPanel, closeSearchPanel, searchPanelOpen } from '@codemirror/search';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
/** Patches `title` attributes onto CodeMirror search-panel controls for icon-only tooltips. */
const buttonTooltips: Record<string, string> = {
next: 'Next match',
prev: 'Previous match',
select: 'Select all matches',
replace: 'Replace',
replaceAll: 'Replace all',
close: 'Close',
};
const checkboxTooltips: Record<string, string> = {
case: 'Match case',
re: 'Regular expression',
word: 'Match whole word',
};
function patchSearchTooltips(root: HTMLElement) {
const panel = root.querySelector('.cm-search');
if (!panel) return;
for (const [name, title] of Object.entries(buttonTooltips)) {
const btn = panel.querySelector(`button[name="${name}"]`) as HTMLElement | null;
if (btn && !btn.title) btn.title = title;
}
for (const [name, title] of Object.entries(checkboxTooltips)) {
const input = panel.querySelector(`input[name="${name}"]`) as HTMLElement | null;
const label = input?.parentElement;
if (label && !label.title) label.title = title;
}
}
export type BlockWidgetDef = {
afterLine: number;
id: string;
@@ -153,6 +184,7 @@ export function CodeMirrorEditor({
blockWidgets,
enableSearch,
searchOpen,
onSearchOpenChange,
}: CodeMirrorEditorProps) {
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
@@ -160,6 +192,7 @@ export function CodeMirrorEditor({
const onChangeRef = React.useRef(onChange);
const onViewReadyRef = React.useRef(onViewReady);
const onViewDestroyRef = React.useRef(onViewDestroy);
const onSearchOpenChangeRef = React.useRef(onSearchOpenChange);
const blockWidgetsRef = React.useRef(blockWidgets);
// Scoped map for widget containers to avoid global collisions and memory leaks
@@ -224,6 +257,10 @@ export function CodeMirrorEditor({
onViewDestroyRef.current = onViewDestroy;
}, [onViewReady, onViewDestroy]);
React.useEffect(() => {
onSearchOpenChangeRef.current = onSearchOpenChange;
}, [onSearchOpenChange]);
React.useEffect(() => {
blockWidgetsRef.current = blockWidgets;
syncPortalWidgets(blockWidgets);
@@ -256,6 +293,12 @@ export function CodeMirrorEditor({
if (update.viewportChanged || update.geometryChanged) {
syncPortalWidgets(blockWidgetsRef.current);
}
// Detect search panel open/close and sync back to React state
const wasOpen = searchPanelOpen(update.startState);
const isOpen = searchPanelOpen(update.state);
if (wasOpen !== isOpen) {
onSearchOpenChangeRef.current?.(isOpen);
}
if (!update.docChanged) {
return;
}
@@ -329,6 +372,10 @@ export function CodeMirrorEditor({
}
if (searchOpen) {
openSearchPanelCompat(view);
// Patch tooltips after panel DOM is mounted
requestAnimationFrame(() => {
patchSearchTooltips(view.dom);
});
} else {
closeSearchPanelCompat(view);
}
@@ -49,7 +49,7 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
const cachedSessionCount = messages.size;
return (
<Card className="fixed bottom-4 right-4 w-96 p-4 shadow-none z-50 bg-background/95 backdrop-blur bottom-safe-area">
<Card className="fixed bottom-4 right-4 w-96 p-4 shadow-none z-50 bg-background/95 bottom-safe-area">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<RiDatabase2Line className="h-4 w-4" />
@@ -34,6 +34,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
const [horizontal, setHorizontal] = React.useState<ThumbMetrics>({ length: 0, offset: 0 });
const hideTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const frameRef = React.useRef<number | null>(null);
const metricsFrameRef = React.useRef<number | null>(null);
const isDraggingRef = React.useRef(false);
const lastUserIntentAtRef = React.useRef(0);
const dragStartRef = React.useRef<{
@@ -76,6 +77,14 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
}
}, [containerRef, minThumbSize, disableHorizontal]);
const scheduleMetricsUpdate = React.useCallback(() => {
if (metricsFrameRef.current !== null) return;
metricsFrameRef.current = requestAnimationFrame(() => {
metricsFrameRef.current = null;
updateMetrics();
});
}, [updateMetrics]);
const scheduleHide = React.useCallback(() => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
@@ -141,13 +150,15 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
const resizeObserver =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => updateMetrics())
? new ResizeObserver(() => {
scheduleMetricsUpdate();
})
: null;
resizeObserver?.observe(container);
const mutationObserver =
observeMutations && typeof MutationObserver !== "undefined"
? new MutationObserver(() => updateMetrics())
? new MutationObserver(() => scheduleMetricsUpdate())
: null;
mutationObserver?.observe(container, { childList: true, subtree: true, characterData: true });
@@ -163,8 +174,9 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
mutationObserver?.disconnect();
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
if (frameRef.current) cancelAnimationFrame(frameRef.current);
if (metricsFrameRef.current) cancelAnimationFrame(metricsFrameRef.current);
};
}, [containerRef, handleScroll, markUserIntent, observeMutations, updateMetrics, userIntentOnly]);
}, [containerRef, handleScroll, markUserIntent, observeMutations, scheduleMetricsUpdate, updateMetrics, userIntentOnly]);
React.useEffect(() => {
if (!suppressVisibility) {
@@ -1,6 +1,7 @@
import React from "react";
import { cn } from "@/lib/utils";
import { OverlayScrollbar } from "./OverlayScrollbar";
import { ScrollShadow } from "./ScrollShadow";
type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
minThumbSize?: number;
@@ -14,6 +15,8 @@ type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
keyboardAvoid?: boolean;
/** Prevent scroll from propagating to parent when at boundaries */
preventOverscroll?: boolean;
useScrollShadow?: boolean;
scrollShadowSize?: number;
};
export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlayProps>(
@@ -31,6 +34,8 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
fillContainer = true,
keyboardAvoid = false,
preventOverscroll = false,
useScrollShadow = false,
scrollShadowSize,
...rest
}, ref) => {
const containerRef = React.useRef<HTMLElement | null>(null);
@@ -46,20 +51,39 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
)}
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
>
<Component
ref={containerRef as React.Ref<HTMLElement>}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
)}
style={style}
{...rest}
>
{children}
</Component>
{useScrollShadow ? (
<ScrollShadow
ref={containerRef as React.Ref<HTMLDivElement>}
size={scrollShadowSize}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
)}
style={style as React.CSSProperties}
observeMutations={observeMutations}
{...rest}
>
{children}
</ScrollShadow>
) : (
<Component
ref={containerRef as React.Ref<HTMLElement>}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
)}
style={style}
{...rest}
>
{children}
</Component>
)}
<OverlayScrollbar
containerRef={containerRef}
minThumbSize={minThumbSize}
@@ -1,21 +0,0 @@
import * as React from "react"
import { Button, type buttonVariants } from "./button"
import { cn } from "@/lib/utils"
import { type VariantProps } from "class-variance-authority"
function ButtonLarge({
className,
variant,
...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
return (
<Button
variant={variant}
size="sm"
className={cn("h-7 px-2 py-0", className)}
{...props}
/>
)
}
export { ButtonLarge }
@@ -1,22 +0,0 @@
import * as React from "react"
import { Button, type buttonVariants } from "./button"
import { cn } from "@/lib/utils"
import { type VariantProps } from "class-variance-authority"
function ButtonSmall({
className,
variant,
size = "sm",
...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
return (
<Button
variant={variant}
size={size}
className={cn(size === "sm" && "h-7 px-2", className)}
{...props}
/>
)
}
export { ButtonSmall }
+5 -5
View File
@@ -6,16 +6,16 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg typography-ui-label font-medium transition-[background-color,border-color,color,box-shadow,opacity] duration-150 ease-in-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg typography-ui-label font-medium lowercase transition-[background-color,border-color,color,box-shadow,opacity] duration-150 ease-in-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-none hover:bg-primary/90",
"border bg-[var(--primary-base)]/10 text-[var(--primary-base)] shadow-none hover:bg-[var(--primary-base)]/15 hover:text-[var(--primary-base)]",
destructive:
"bg-destructive text-white shadow-none hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-[var(--status-error)]/8 text-[var(--status-error)] shadow-none hover:bg-[var(--status-error)]/12 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-none hover:bg-interactive-hover hover:text-foreground",
"border bg-[var(--surface-elevated)] shadow-none hover:bg-interactive-hover hover:text-foreground",
secondary:
"bg-interactive-hover text-foreground shadow-none hover:bg-interactive-active",
ghost:
@@ -25,7 +25,7 @@ const buttonVariants = cva(
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5",
xs: "h-6 rounded-md gap-1 px-1.5 typography-micro has-[>svg]:px-1.5",
xs: "h-6 rounded-lg gap-1 px-1.5 typography-micro has-[>svg]:px-1.5",
lg: "h-10 rounded-lg px-6 has-[>svg]:px-4",
icon: "size-9",
},
@@ -43,6 +43,8 @@ function DropdownMenuContent({
style={{
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
...style,
}}
className={cn(
@@ -236,6 +238,8 @@ function DropdownMenuSubContent({
style={{
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
}}
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-visible rounded-xl border-2 border-border/60 p-1 shadow-none",
+2
View File
@@ -67,6 +67,8 @@ function SelectContent({
style={{
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
}}
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden rounded-xl border-2 border-border/60 shadow-none transform-gpu will-change-transform",
+7 -5
View File
@@ -14,13 +14,15 @@ const Toaster = ({ ...props }: ToasterProps) => {
closeButton={false}
toastOptions={{
style: {
borderRadius: "var(--radius-md)",
borderRadius: "var(--radius-lg)",
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
},
classNames: {
toast: "rounded-[var(--radius-md)]",
actionButton: "rounded-[var(--radius-sm)]",
cancelButton: "rounded-[var(--radius-sm)]",
closeButton: "rounded-[var(--radius-sm)]",
toast: "rounded-[var(--radius-lg)]",
actionButton: "rounded-[var(--radius-md)]",
cancelButton: "rounded-[var(--radius-md)]",
closeButton: "rounded-[var(--radius-md)]",
},
}}
style={
@@ -41,6 +41,7 @@ type SortableTabsStripProps = {
activePillButtonClassName?: string;
inactiveTabsIconOnly?: boolean;
animateActivePill?: boolean;
activePillLowercase?: boolean;
className?: string;
};
@@ -92,6 +93,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
activePillButtonClassName,
inactiveTabsIconOnly = false,
animateActivePill,
activePillLowercase = true,
className,
}) => {
const isMobile = useUIStore((state) => state.isMobile);
@@ -107,9 +109,10 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
const shouldAnimateActivePill = animateActivePill ?? isAnimatedVariant;
const reorderEnabled = typeof onReorder === 'function';
const Wrapper = reorderEnabled ? SortableTabWrapper : StaticTabWrapper;
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
const tabRefs = React.useRef<Map<string, HTMLElement>>(new Map());
const [pillRect, setPillRect] = React.useState<{ left: number; top: number; width: number; height: number } | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
@@ -127,7 +130,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
&& Math.abs(a.height - b.height) < 0.5;
}, []);
const setTabRef = React.useCallback((id: string, element: HTMLButtonElement | null) => {
const setTabRef = React.useCallback((id: string, element: HTMLElement | null) => {
if (element) {
tabRefs.current.set(id, element);
return;
@@ -148,14 +151,24 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
return;
}
const containerRect = container.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
// Walk offsetParent chain to compute position relative to the scroll container.
// Unlike getBoundingClientRect, offsetLeft/offsetTop are unaffected by CSS
// transforms (e.g. dropdown entry scale animation), preventing pill mis-positioning
// on first render.
let left = 0;
let top = 0;
let el: HTMLElement | null = activeTab;
while (el && el !== container) {
left += el.offsetLeft;
top += el.offsetTop;
el = el.offsetParent as HTMLElement | null;
}
const nextRect = {
left: tabRect.left - containerRect.left + container.scrollLeft,
top: tabRect.top - containerRect.top + container.scrollTop,
width: tabRect.width,
height: tabRect.height,
left,
top,
width: activeTab.offsetWidth,
height: activeTab.offsetHeight,
};
setPillRect((prev) => (isSamePillRect(prev, nextRect) ? prev : nextRect));
@@ -233,6 +246,8 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
updateActivePillRect();
});
React.useEffect(() => {
if (!isScrollable || !activeId) {
return;
@@ -276,11 +291,25 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
const list = (
<div className={cn('relative flex h-full min-w-0 flex-1', className)}>
{isScrollable && !usesActivePillIndicator && overflow.left ? (
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-background to-transparent" />
{isScrollable && overflow.left ? (
<div
className={cn(
'pointer-events-none absolute inset-y-0 left-0 z-20 bg-gradient-to-r to-transparent',
usesActivePillIndicator
? 'w-8 from-[var(--surface-background)]'
: 'w-6 from-background'
)}
/>
) : null}
{isScrollable && !usesActivePillIndicator && overflow.right ? (
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-background to-transparent" />
{isScrollable && overflow.right ? (
<div
className={cn(
'pointer-events-none absolute inset-y-0 right-0 z-20 bg-gradient-to-l to-transparent',
usesActivePillIndicator
? 'w-8 from-[var(--surface-background)]'
: 'w-6 from-background'
)}
/>
) : null}
<div
ref={scrollRef}
@@ -328,6 +357,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
return (
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
<div
ref={(element) => setTabRef(item.id, element)}
className={cn(
'group flex h-full items-center',
(isScrollable || useIntrinsicPillSizing)
@@ -343,7 +373,6 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
)}
>
<button
ref={(element) => setTabRef(item.id, element)}
type="button"
role="tab"
aria-selected={isActive}
@@ -353,6 +382,8 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
usesActivePillIndicator
? 'animated-tabs__button pill-tabs__button relative z-10 flex flex-1 items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 !min-h-0'
: 'flex h-full min-w-0 items-center typography-micro',
usesActivePillIndicator && closable && '!flex-none',
usesActivePillIndicator && activePillLowercase ? 'lowercase' : null,
usesActivePillIndicator && (showInactiveIconOnly ? 'gap-0' : 'gap-1.5'),
usesActivePillIndicator
? useIntrinsicPillSizing
@@ -396,15 +427,25 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
{closable ? (
<button
type="button"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onClose?.(item.id);
}}
className={cn(
'mr-1 inline-flex aspect-square h-[65%] min-h-4 max-h-5 !min-h-0 !min-w-0 items-center justify-center rounded-sm transition-opacity',
isActive
? 'text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground'
: 'text-muted-foreground opacity-0 hover:bg-interactive-hover/80 hover:text-foreground group-hover:opacity-100'
'relative z-20 inline-flex !min-h-0 !min-w-0 items-center justify-center transition-opacity',
usesActivePillIndicator
? '-ml-2.5 mr-1 h-[88%] w-5 self-center !aspect-auto rounded-md'
: 'aspect-square h-[65%] min-h-4 max-h-5 rounded-sm mr-1',
usesActivePillIndicator
? (isActive
? 'text-muted-foreground hover:bg-transparent hover:text-foreground'
: 'text-muted-foreground opacity-0 hover:bg-transparent hover:text-foreground group-hover:opacity-100')
: (isActive
? 'text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground'
: 'text-muted-foreground opacity-0 hover:bg-interactive-hover/80 hover:text-foreground group-hover:opacity-100')
)}
aria-label={item.closeLabel ?? `Close ${item.label} tab`}
title={item.closeLabel ?? `Close ${item.label} tab`}
+1 -2
View File
@@ -71,10 +71,9 @@ const variants = [
className={cn(
"inline-block whitespace-pre align-baseline"
)}
initial={{ opacity: 0, filter: "blur(3px)" }}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
filter: "blur(0px)",
}}
transition={{
ease: "easeOut",
+7 -1
View File
@@ -39,6 +39,7 @@ function TooltipContent({
className,
sideOffset = 0,
children,
style,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
@@ -50,10 +51,15 @@ function TooltipContent({
"bg-muted text-muted-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-xl px-3 py-1.5 typography-meta text-balance overflow-hidden transform-gpu will-change-transform",
className
)}
style={{
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
...style,
}}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-muted fill-muted z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xl" />
<TooltipPrimitive.Arrow className="fill-muted z-50 size-2" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
+206 -368
View File
@@ -461,7 +461,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const searchInputRef = React.useRef<HTMLInputElement>(null);
const [showMobilePageContent, setShowMobilePageContent] = React.useState(false);
const [wrapLines, setWrapLines] = React.useState(isMobile);
const [wrapLines, setWrapLines] = React.useState(true);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
@@ -2114,6 +2114,202 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
);
}, [currentTheme.metadata.variant, pierreTheme, wrapLines]);
const renderFloatingFileControls = ({ exitFullscreenOnly = false }: { exitFullscreenOnly?: boolean } = {}) => {
if (!selectedFile) {
return null;
}
return (
<div className="pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/95 p-1 shadow-sm backdrop-blur-sm">
{canEdit && textViewMode === 'edit' && (
isSaving ? (
<span className="flex items-center gap-1 px-1 text-muted-foreground typography-meta">
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
Saving...
</span>
) : autoSaveStatus === 'saved' && !isDirty ? (
<span className="flex items-center gap-1 px-1 text-[color:var(--status-success)] typography-meta">
<RiCheckLine className="h-3.5 w-3.5" />
Saved
</span>
) : isDirty ? (
<Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
className="h-6 px-1 gap-1 text-muted-foreground opacity-80 hover:opacity-100"
title={`Save now (${getModifierLabel()}+S) - auto-saves after 1.5s`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
<RiSave3Line className="h-4 w-4" />
</Button>
) : null
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground opacity-80 hover:opacity-100"
title="Open in desktop app"
aria-label="Open in desktop app"
>
<RiFileTransferLine className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 max-h-[70vh] overflow-y-auto">
{openInApps.map((app) => (
<DropdownMenuItem
key={app.id}
className="flex items-center gap-2"
onClick={() => void handleOpenInApp(app)}
>
<OpenInAppListIcon label={app.label} iconDataUrl={app.iconDataUrl} />
<span className="typography-ui-label text-foreground">{app.label}</span>
</DropdownMenuItem>
))}
{openInCacheStale ? (
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => void loadOpenInApps(true)}
>
<RiRefreshLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Refresh Apps</span>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{!isSelectedImage && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setWrapLines(!wrapLines)}
className={cn(
'h-6 w-6 p-0 transition-opacity',
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
)}
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
>
<RiTextWrap className="size-4" />
</Button>
{textViewMode === 'edit' && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchOpen(!isSearchOpen)}
className={cn(
'h-6 w-6 p-0 transition-opacity',
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
)}
title="Find in file"
>
<RiSearchLine className="size-4" />
</Button>
)}
</>
)}
{isMarkdown && (
<PreviewToggleButton
currentMode={getMdViewMode()}
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
/>
)}
{canCopy && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
const result = await copyTextToClipboard(fileContent);
if (result.ok) {
setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} else {
toast.error('Copy failed');
}
}}
className="h-6 w-6 p-0"
title="Copy file contents"
aria-label="Copy file contents"
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
)}
{canCopyPath && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
const result = await copyTextToClipboard(displaySelectedPath);
if (result.ok) {
setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current);
}
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} else {
toast.error('Copy failed');
}
}}
className="h-6 w-6 p-0"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiFileCopy2Line className="h-4 w-4" />
)}
</Button>
)}
{exitFullscreenOnly ? (
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(false)}
className="h-6 w-6 p-0"
title="Exit fullscreen"
aria-label="Exit fullscreen"
>
<RiFullscreenExitLine className="h-4 w-4" />
</Button>
) : (!isMobile && mode === 'full' && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
className="h-6 w-6 p-0"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<RiFullscreenExitLine className="h-4 w-4" />
) : (
<RiFullscreenLine className="h-4 w-4" />
)}
</Button>
))}
</div>
);
};
const fileViewer = (
<div
className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden"
@@ -2144,7 +2340,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex flex-col border-b border-border/40 flex-shrink-0">
<div className={cn('flex flex-col flex-shrink-0', showEditorTabsRow && 'border-b border-border/40')}>
{/* Row 1: Tabs */}
{showEditorTabsRow ? (
<div className="flex min-w-0 items-center px-3 py-1.5">
@@ -2288,199 +2484,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
) : null}
{/* Row 2: Actions (right-aligned) */}
{selectedFile && (
<div className={cn('flex items-center justify-end gap-1 px-3 pb-1.5', !showEditorTabsRow && 'pt-1.5')}>
{canEdit && textViewMode === 'edit' && (
isSaving ? (
<span className="flex items-center gap-1 text-muted-foreground typography-meta">
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
Saving
</span>
) : autoSaveStatus === 'saved' && !isDirty ? (
<span className="flex items-center gap-1 text-[color:var(--status-success)] typography-meta">
<RiCheckLine className="h-3.5 w-3.5" />
Saved
</span>
) : isDirty ? (
<Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
className="h-5 px-1 gap-1 text-muted-foreground opacity-70 hover:opacity-100"
title={`Save now (${getModifierLabel()}+S) — auto-saves after 1.5s`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
<RiSave3Line className="h-3.5 w-3.5" />
</Button>
) : null
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-muted-foreground opacity-70 hover:opacity-100"
title="Open in desktop app"
aria-label="Open in desktop app"
>
<RiFileTransferLine className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 max-h-[70vh] overflow-y-auto">
{openInApps.map((app) => (
<DropdownMenuItem
key={app.id}
className="flex items-center gap-2"
onClick={() => void handleOpenInApp(app)}
>
<OpenInAppListIcon label={app.label} iconDataUrl={app.iconDataUrl} />
<span className="typography-ui-label text-foreground">{app.label}</span>
</DropdownMenuItem>
))}
{openInCacheStale ? (
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => void loadOpenInApps(true)}
>
<RiRefreshLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Refresh Apps</span>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{canEdit && !isSelectedImage && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{!isSelectedImage && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setWrapLines(!wrapLines)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
>
<RiTextWrap className="size-4" />
</Button>
{textViewMode === 'edit' && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchOpen(!isSearchOpen)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title="Find in file"
>
<RiSearchLine className="size-4" />
</Button>
)}
</>
)}
{(canCopy || canCopyPath || isMarkdown) && (canEdit || !isSelectedImage) && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{isMarkdown && (
<PreviewToggleButton
currentMode={getMdViewMode()}
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
/>
)}
{canCopy && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
const result = await copyTextToClipboard(fileContent);
if (result.ok) {
setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} else {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title="Copy file contents"
aria-label="Copy file contents"
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
)}
{canCopyPath && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
const result = await copyTextToClipboard(displaySelectedPath);
if (result.ok) {
setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current);
}
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} else {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiFileCopy2Line className="h-4 w-4" />
)}
</Button>
)}
{!isMobile && mode === 'full' && (
<>
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
className="h-5 w-5 p-0"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<RiFullscreenExitLine className="h-4 w-4" />
) : (
<RiFullscreenLine className="h-4 w-4" />
)}
</Button>
</>
)}
</div>
)}
</div>
<div className="flex-1 min-h-0 min-w-0 relative">
{selectedFile && !isSearchOpen && (
<div className="absolute right-3 top-3 z-30">
{renderFloatingFileControls()}
</div>
)}
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">Pick a file from the tree.</div>
@@ -2747,184 +2758,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
// Fullscreen file viewer overlay
const fullscreenViewer = mode === 'full' && isFullscreen && selectedFile && (
<div className="absolute inset-0 z-50 flex flex-col bg-background">
{/* Fullscreen header */}
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-4 py-2 flex-shrink-0">
<div className="min-w-0 flex-1">
<div className="typography-ui-label font-medium truncate">
{selectedFile.name}
</div>
<div className="typography-meta text-muted-foreground truncate" title={displaySelectedPath}>
{displaySelectedPath}
</div>
</div>
<div className="flex items-center gap-1">
{canEdit && textViewMode === 'edit' && (
isSaving ? (
<span className="flex items-center gap-1 text-muted-foreground typography-meta">
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
Saving
</span>
) : autoSaveStatus === 'saved' && !isDirty ? (
<span className="flex items-center gap-1 text-[color:var(--status-success)] typography-meta">
<RiCheckLine className="h-3.5 w-3.5" />
Saved
</span>
) : isDirty ? (
<Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
className="h-6 px-1 gap-1 text-muted-foreground opacity-70 hover:opacity-100"
title={`Save now (${getModifierLabel()}+S) — auto-saves after 1.5s`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
<RiSave3Line className="h-4 w-4" />
</Button>
) : null
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground opacity-70 hover:opacity-100"
title="Open in desktop app"
aria-label="Open in desktop app"
>
<RiFileTransferLine className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 max-h-[70vh] overflow-y-auto">
{openInApps.map((app) => (
<DropdownMenuItem
key={app.id}
className="flex items-center gap-2"
onClick={() => void handleOpenInApp(app)}
>
<OpenInAppListIcon label={app.label} iconDataUrl={app.iconDataUrl} />
<span className="typography-ui-label text-foreground">{app.label}</span>
</DropdownMenuItem>
))}
{openInCacheStale ? (
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => void loadOpenInApps(true)}
>
<RiRefreshLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Refresh Apps</span>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{canEdit && !isSelectedImage && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{!isSelectedImage && (
<Button
variant="ghost"
size="sm"
onClick={() => setWrapLines(!wrapLines)}
className={cn(
'h-6 w-6 p-0 transition-opacity',
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
>
<RiTextWrap className="size-4" />
</Button>
)}
{(canCopy || canCopyPath || isMarkdown) && (canEdit || !isSelectedImage) && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{isMarkdown && (
<PreviewToggleButton
currentMode={getMdViewMode()}
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
/>
)}
{canCopy && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
const result = await copyTextToClipboard(fileContent);
if (result.ok) {
setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} else {
toast.error('Copy failed');
}
}}
className="h-6 w-6 p-0"
title="Copy file contents"
aria-label="Copy file contents"
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
)}
{canCopyPath && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
const result = await copyTextToClipboard(displaySelectedPath);
if (result.ok) {
setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current);
}
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} else {
toast.error('Copy failed');
}
}}
className="h-6 w-6 p-0"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiFileCopy2Line className="h-4 w-4" />
)}
</Button>
)}
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(false)}
className="h-6 w-6 p-0"
title="Exit fullscreen"
aria-label="Exit fullscreen"
>
<RiFullscreenExitLine className="h-4 w-4" />
</Button>
</div>
</div>
{/* Fullscreen content */}
<div className="flex-1 min-h-0 min-w-0 relative">
<div className="absolute right-4 top-4 z-30">
{renderFloatingFileControls({ exitFullscreenOnly: true })}
</div>
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{fileLoading ? (
suppressFileLoadingIndicator
+53 -7
View File
@@ -433,6 +433,7 @@ export const GitView: React.FC = () => {
return isActionTab(stored) ? stored : 'commit';
});
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
const [removingRemoteName, setRemovingRemoteName] = React.useState<string | null>(null);
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
@@ -585,14 +586,23 @@ export const GitView: React.FC = () => {
git.getRemoteUrl(currentDirectory).then(setRemoteUrl).catch(() => setRemoteUrl(null));
}, [currentDirectory, git]);
React.useEffect(() => {
const refreshRemotes = React.useCallback(async () => {
if (!currentDirectory || !git?.getRemotes) {
setRemotes([]);
return;
}
git.getRemotes(currentDirectory).then(setRemotes).catch(() => setRemotes([]));
try {
const remoteList = await git.getRemotes(currentDirectory);
setRemotes(remoteList);
} catch {
setRemotes([]);
}
}, [currentDirectory, git]);
React.useEffect(() => {
void refreshRemotes();
}, [refreshRemotes]);
React.useEffect(() => {
if (!settingsGitmojiEnabled) {
setGitmojiEmojis([]);
@@ -825,6 +835,35 @@ export const GitView: React.FC = () => {
}
};
const handleRemoveRemote = React.useCallback(async (remote: GitRemote) => {
if (!currentDirectory) return;
const remoteName = remote.name.trim();
if (!remoteName) {
toast.error('Remote name is required');
return;
}
if (remoteName === 'origin') {
toast.error('Cannot remove origin remote');
return;
}
setRemovingRemoteName(remoteName);
try {
await git.removeRemote(currentDirectory, { remote: remoteName });
toast.success(`Removed ${remoteName} remote`);
await Promise.all([
refreshStatusAndBranches(false),
refreshRemotes(),
]);
} catch (error) {
const message = error instanceof Error ? error.message : `Failed to remove ${remoteName}`;
toast.error(message);
} finally {
setRemovingRemoteName(null);
}
}, [currentDirectory, git, refreshRemotes, refreshStatusAndBranches]);
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
if (!currentDirectory) return;
if (!commitMessage.trim()) {
@@ -1254,11 +1293,20 @@ export const GitView: React.FC = () => {
}
const insertBeforeIndex = log.all.findIndex((entry) => !branchHashes.has(entry.hash));
if (insertBeforeIndex <= 0) {
if (insertBeforeIndex === 0) {
setHistoryBranchDivider(null);
return;
}
if (insertBeforeIndex === -1) {
setHistoryBranchDivider({
insertBeforeIndex: log.all.length,
branchName: currentBranch,
direction: 'up',
});
return;
}
setHistoryBranchDivider({
insertBeforeIndex,
branchName: currentBranch,
@@ -1806,6 +1854,8 @@ export const GitView: React.FC = () => {
onFetch={(remote) => handleSyncAction('fetch', remote)}
onPull={(remote) => handleSyncAction('pull', remote)}
onPush={() => handleSyncAction('push')}
onRemoveRemote={handleRemoveRemote}
removingRemoteName={removingRemoteName}
onCheckoutBranch={handleCheckoutBranch}
onCreateBranch={handleCreateBranch}
onRenameBranch={handleRenameBranch}
@@ -1861,7 +1911,6 @@ export const GitView: React.FC = () => {
{(changeEntries?.length ?? 0) > 0 ? (
<>
<ChangesSection
variant="plain"
maxListHeightClassName="max-h-[40vh]"
changeEntries={changeEntries}
onVisiblePathsChange={setVisibleChangePaths}
@@ -1887,7 +1936,6 @@ export const GitView: React.FC = () => {
/>
<CommitSection
variant="plain"
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
@@ -1945,7 +1993,6 @@ export const GitView: React.FC = () => {
<div className="space-y-4">
{integrateCommitsProps ? (
<IntegrateCommitsSection
variant="plain"
repoRoot={integrateCommitsProps.repoRoot}
sourceBranch={integrateCommitsProps.sourceBranch}
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
@@ -1974,7 +2021,6 @@ export const GitView: React.FC = () => {
<div className="space-y-4">
{pullRequestProps ? (
<PullRequestSection
variant="plain"
directory={pullRequestProps.directory}
branch={pullRequestProps.branch}
baseBranch={baseBranch}
@@ -240,6 +240,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const isMobile = forceMobile ?? deviceInfo.isMobile;
const settingsPageRaw = useUIStore((state) => state.settingsPage);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
@@ -322,25 +323,27 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
// Load stores when project changes or when a page becomes active.
React.useEffect(() => {
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode) {
return;
}
if (settingsSlug === 'agents') {
setTimeout(() => void useAgentsStore.getState().loadAgents(), 0);
void useAgentsStore.getState().loadAgents();
return;
}
if (settingsSlug === 'commands') {
setTimeout(() => void useCommandsStore.getState().loadCommands(), 0);
void useCommandsStore.getState().loadCommands();
return;
}
if (settingsSlug === 'mcp') {
setTimeout(() => void useMcpConfigStore.getState().loadMcpConfigs(), 0);
void useMcpConfigStore.getState().loadMcpConfigs();
return;
}
if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') {
setTimeout(() => {
void useSkillsStore.getState().loadSkills();
void useSkillsCatalogStore.getState().loadCatalog();
}, 0);
void useSkillsStore.getState().loadSkills();
void useSkillsCatalogStore.getState().loadCatalog();
}
}, [activeProjectId, settingsSlug]);
}, [activeProjectId, isSettingsDialogOpen, runtimeCtx.isVSCode, settingsSlug]);
const openPage = React.useCallback((slug: SettingsPageSlug) => {
setSettingsPage(slug);
@@ -579,7 +582,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const renderMobileStage = () => {
if (mobileStage === 'nav') {
return (
<div className={cn('flex-1 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
<div className="flex h-full min-h-0 flex-col">
<ErrorBoundary>{renderSettingsNav(false)}</ErrorBoundary>
</div>
@@ -596,13 +599,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
// No sidebar available; fall back to direct content.
const fallback = renderPageContent(settingsSlug);
return (
<div className="flex-1 overflow-hidden bg-background" data-keyboard-avoid="true">
<div className="flex-1 min-h-0 overflow-hidden bg-background" data-keyboard-avoid="true">
<ErrorBoundary>{fallback}</ErrorBoundary>
</div>
);
}
return (
<div className={cn('flex-1 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
<ErrorBoundary>
{renderPageSidebar(settingsSlug, { onItemSelect: () => setMobileStage('page-content') })}
</ErrorBoundary>
@@ -614,7 +617,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const content = renderPageContent(settingsSlug);
return (
<div className="flex-1 overflow-hidden bg-background" data-keyboard-avoid="true">
<div className="flex-1 min-h-0 overflow-hidden bg-background" data-keyboard-avoid="true">
<ErrorBoundary>{content}</ErrorBoundary>
</div>
);
@@ -646,7 +649,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
};
return (
<div ref={containerRef} data-settings-view="true" className={cn('relative flex h-full flex-col overflow-hidden bg-background')}>
<div ref={containerRef} data-settings-view="true" className={cn('relative flex h-full min-h-0 flex-col overflow-hidden bg-background')}>
{isMobile ? (
<div
className={cn(
@@ -724,7 +727,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
</>
)}
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 min-h-0 overflow-hidden">
{isMobile ? (
renderMobileStage()
) : (
@@ -733,7 +736,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
className={cn(
'relative flex h-full min-h-0 flex-col overflow-hidden border-r',
isDesktopApp
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
? 'bg-[color:var(--sidebar-overlay-strong)] supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: runtimeCtx.isVSCode
? 'bg-background'
: 'bg-sidebar',
@@ -14,7 +14,6 @@ interface SettingsWindowProps {
*/
export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChange }) => {
const descriptionId = React.useId();
const skipNextOverlayClickRef = React.useRef(false);
const hasOpenFloatingMenu = React.useCallback(() => {
if (typeof document === 'undefined') {
@@ -31,15 +30,8 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-md"
onPointerDown={() => {
skipNextOverlayClickRef.current = hasOpenFloatingMenu();
}}
onClick={(event) => {
onPointerDown={(event) => {
event.stopPropagation();
if (skipNextOverlayClickRef.current) {
skipNextOverlayClickRef.current = false;
return;
}
if (hasOpenFloatingMenu()) {
return;
}
@@ -228,7 +228,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-10 w-10 flex-shrink-0" aria-label="Worktree actions">
<Button variant="outline" size="icon" className="flex-shrink-0" aria-label="Worktree actions">
<RiMore2Line className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -198,13 +198,13 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
{operationCompleted ? (
mode === 'dialog' ? (
<DialogFooter>
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleClose}>
<Button variant="default" size="sm" onClick={handleClose}>
{hasError ? 'Close' : 'Done'}
</Button>
</DialogFooter>
) : (
<div className="flex justify-end">
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleClose}>
<Button variant="default" size="sm" onClick={handleClose}>
{hasError ? 'Close' : 'Done'}
</Button>
</div>
@@ -284,7 +284,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
</p>
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen} modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-between h-10">
<Button variant="outline" size="lg" className="w-full justify-between">
<span className={cn('truncate', !selectedBranch && 'text-muted-foreground')}>
{selectedBranch || 'Select a branch...'}
</span>
@@ -353,7 +353,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
{mode === 'dialog' ? (
<DialogFooter className="gap-2 pt-1">
<Button variant="ghost" size="sm" className="h-7 px-2 py-0" onClick={handleCancel}>
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button
@@ -361,7 +361,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
size="sm"
onClick={handleConfirm}
disabled={!selectedBranch}
className="h-7 px-2 py-0 gap-1.5"
className="gap-1.5"
>
{operation === 'merge' ? (
<>
@@ -378,11 +378,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
</DialogFooter>
) : (
<div className="flex items-center gap-2 pt-1">
<Button variant="ghost" size="sm" className="h-7 px-2 py-0" onClick={handleCancel} disabled={isDisabled}>
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={isDisabled}>
Reset
</Button>
<div className="flex-1" />
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
<Button variant="default" size="sm" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
{operation === 'merge' ? 'Merge' : 'Rebase'}
</Button>
</div>
@@ -416,7 +416,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
<Button
variant="outline"
size="sm"
className="h-7 px-2 py-0 gap-1.5"
className="gap-1.5"
onClick={handleOpenDialog}
disabled={isDisabled}
>
@@ -28,7 +28,6 @@ interface ChangesSectionProps {
onViewDiff: (path: string) => void;
onRevertFile: (path: string) => void;
isRevertingAll?: boolean;
variant?: 'framed' | 'plain';
maxListHeightClassName?: string;
onVisiblePathsChange?: (paths: string[]) => void;
}
@@ -48,7 +47,6 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
onViewDiff,
onRevertFile,
isRevertingAll = false,
variant = 'framed',
maxListHeightClassName,
onVisiblePathsChange,
}) => {
@@ -92,19 +90,10 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
onVisiblePathsChange(virtualRows.map((row) => changeEntries[row.index]?.path).filter((value): value is string => Boolean(value)));
}, [changeEntries, onVisiblePathsChange, shouldVirtualize, totalCount, virtualRows]);
const containerClassName =
variant === 'framed'
? 'flex flex-col rounded-xl border border-border/60 bg-background/70'
: 'flex flex-col flex-1 min-h-0';
const headerClassName =
variant === 'framed'
? 'flex items-center justify-between gap-2 px-3 py-2 border-b border-border/40'
: 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
const scrollOuterClassName =
variant === 'framed'
? 'flex-1 min-h-0 max-h-[30vh]'
: `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
const rowPaddingClassName = variant === 'plain' ? 'pl-0 pr-2' : 'px-3';
const containerClassName = 'flex flex-col flex-1 min-h-0';
const headerClassName = 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
const scrollOuterClassName = `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
const rowPaddingClassName = 'pl-0 pr-2';
const handleConfirmRevertAll = React.useCallback(async () => {
if (!onRevertAll || isRevertingAll || changeEntries.length === 0) {
@@ -144,12 +133,11 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
</button>
) : null}
</div>
<div className={cn('flex items-center gap-2', variant === 'plain' && 'pr-1')}>
<div className="flex items-center gap-2 pr-1">
{totalCount > 0 && onRevertAll ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-[var(--status-error)] hover:text-[var(--status-error)]"
variant="destructive"
size="xs"
onClick={() => setConfirmRevertAllOpen(true)}
disabled={isRevertingAll}
>
@@ -237,10 +225,10 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmRevertAllOpen(false)} disabled={isRevertingAll}>
<Button variant="outline" size="sm" onClick={() => setConfirmRevertAllOpen(false)} disabled={isRevertingAll}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmRevertAll()} disabled={isRevertingAll}>
<Button variant="destructive" size="sm" onClick={() => void handleConfirmRevertAll()} disabled={isRevertingAll}>
{isRevertingAll ? 'Reverting...' : 'Revert all'}
</Button>
</DialogFooter>
@@ -5,12 +5,7 @@ import {
RiLoader4Line,
RiEmotionHappyLine,
} from '@remixicon/react';
import {
Collapsible,
CollapsibleContent,
} from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { CommitInput } from './CommitInput';
import { AIHighlightsBox } from './AIHighlightsBox';
import { useDeviceInfo } from '@/lib/device';
@@ -33,7 +28,6 @@ interface CommitSectionProps {
isBusy: boolean;
gitmojiEnabled: boolean;
onOpenGitmojiPicker: () => void;
variant?: 'framed' | 'plain';
}
export const CommitSection: React.FC<CommitSectionProps> = ({
@@ -51,167 +45,148 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
isBusy,
gitmojiEnabled,
onOpenGitmojiPicker,
variant = 'framed',
}) => {
const hasSelectedFiles = selectedCount > 0;
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
const { isMobile, hasTouchInput } = useDeviceInfo();
const containerClassName =
variant === 'framed'
? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden'
: 'border-0 bg-transparent rounded-none';
const headerClassName =
variant === 'framed'
? 'flex w-full items-center justify-between px-3 py-2'
: 'flex w-full items-center justify-between px-0 py-3 border-b border-border/40';
const contentClassName =
variant === 'framed'
? 'flex flex-col gap-3 p-3 pt-0'
: 'flex flex-col gap-3 px-0 py-3';
const containerClassName = 'border-0 bg-transparent rounded-none';
const headerClassName = 'flex w-full items-center justify-between px-0 pt-2 pb-1';
const contentClassName = 'flex flex-col gap-3 px-0 pt-1 pb-3';
return (
<Collapsible
open={variant === 'plain' ? true : hasSelectedFiles}
className={containerClassName}
data-keyboard-avoid="true"
>
<section className={containerClassName} data-keyboard-avoid="true">
<div className={headerClassName}>
<h3 className="typography-ui-header font-semibold text-foreground">Commit</h3>
<span className="typography-meta text-muted-foreground">
{hasSelectedFiles
? `${selectedCount} file${selectedCount === 1 ? '' : 's'} selected`
: 'No files selected'}
</span>
</div>
<CollapsibleContent>
<div className={contentClassName}>
{!hasSelectedFiles ? (
<p className="typography-meta text-muted-foreground">
Select files in Changes to enable commit.
</p>
) : null}
<div className={contentClassName}>
{!hasSelectedFiles ? (
<p className="typography-meta text-muted-foreground">
Select files in Changes to enable commit.
</p>
) : null}
<AIHighlightsBox
highlights={generatedHighlights}
onInsert={onInsertHighlights}
onClear={onClearHighlights}
/>
<AIHighlightsBox
highlights={generatedHighlights}
onInsert={onInsertHighlights}
onClear={onClearHighlights}
/>
<CommitInput
value={commitMessage}
onChange={onCommitMessageChange}
placeholder="Commit message"
disabled={commitAction !== null}
hasTouchInput={hasTouchInput}
isMobile={isMobile}
/>
<CommitInput
value={commitMessage}
onChange={onCommitMessageChange}
placeholder="Commit message"
disabled={commitAction !== null}
hasTouchInput={hasTouchInput}
isMobile={isMobile}
/>
{gitmojiEnabled && (
<Button
variant="outline"
size="sm"
onClick={onOpenGitmojiPicker}
className="w-fit"
type="button"
>
<RiEmotionHappyLine className="size-4" />
Add gitmoji
</Button>
)}
{gitmojiEnabled && (
<Button
variant="outline"
size="sm"
onClick={onOpenGitmojiPicker}
className="w-fit"
type="button"
>
<RiEmotionHappyLine className="size-4" />
Add gitmoji
</Button>
)}
<div className="@container/commit-actions flex items-center gap-2 min-w-0">
<Button
variant="outline"
size="sm"
onClick={onGenerateMessage}
disabled={
isGeneratingMessage ||
commitAction !== null ||
selectedCount === 0 ||
isBusy
}
type="button"
aria-label="Generate"
className="commit-actions__btn"
>
{isGeneratingMessage ? (
<div className="@container/commit-actions flex items-center gap-2 min-w-0">
<Button
variant="outline"
size="sm"
onClick={onGenerateMessage}
disabled={
isGeneratingMessage ||
commitAction !== null ||
selectedCount === 0 ||
isBusy
}
type="button"
aria-label="Generate"
className="commit-actions__btn"
>
{isGeneratingMessage ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiAiGenerate2 className="size-4 text-primary" />
)}
<span className="commit-actions__label">Generate</span>
</Button>
<div className="flex-1" />
<Button
size="sm"
variant="outline"
onClick={onCommit}
disabled={!canCommit || isGeneratingMessage}
className="commit-actions__btn whitespace-nowrap"
aria-label="Commit"
>
{commitAction === 'commit' ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiAiGenerate2 className="size-4 text-primary" />
)}
<span className="commit-actions__label">Generate</span>
</Button>
<span className="commit-actions__label">Committing...</span>
</>
) : (
<>
<RiGitCommitLine className="size-4" />
<span className="commit-actions__label">Commit</span>
</>
)}
</Button>
<div className="flex-1" />
<ButtonLarge
variant="outline"
onClick={onCommit}
{isMobile ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="h-7 w-7 p-0"
aria-label="Push"
>
{commitAction === 'commitAndPush' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiArrowUpLine className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Push</p>
</TooltipContent>
</Tooltip>
) : (
<Button
size="sm"
variant="default"
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="commit-actions__btn whitespace-nowrap"
aria-label="Commit"
className="commit-actions__btn"
aria-label="Push"
>
{commitAction === 'commit' ? (
{commitAction === 'commitAndPush' ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
<span className="commit-actions__label">Committing...</span>
<span className="commit-actions__label">Pushing...</span>
</>
) : (
<>
<RiGitCommitLine className="size-4" />
<span className="commit-actions__label">Commit</span>
<RiArrowUpLine className="size-3.5" />
<span className="commit-actions__label">Push</span>
</>
)}
</ButtonLarge>
{isMobile ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="h-7 w-7 p-0"
aria-label="Push"
>
{commitAction === 'commitAndPush' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiArrowUpLine className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Push</p>
</TooltipContent>
</Tooltip>
) : (
<ButtonLarge
variant="default"
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="commit-actions__btn"
aria-label="Push"
>
{commitAction === 'commitAndPush' ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
<span className="commit-actions__label">Pushing...</span>
</>
) : (
<>
<RiArrowUpLine className="size-4" />
<span className="commit-actions__label">Push</span>
</>
)}
</ButtonLarge>
)}
</div>
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
</section>
);
};

Some files were not shown because too many files have changed in this diff Show More